6 Commits
Author SHA1 Message Date
glm-5.3-flash 126ae9ccf6 release: v0.3.2 — the changelog link refs (the 0.3.2 compare line, the Unreleased base catch-up)
- CHANGELOG: the [0.3.2] compare link added (v0.3.1...v0.3.2); the
  [Unreleased] base re-pointed v0.3.0...HEAD -> v0.3.1...HEAD, the
  stale-base residue from the 0.3.1 entry landing without the
  0.3.2 refs.

Verification: cargo test (145 lib / 375 total), --all-features (171),
clippy -D warnings (default + wasm targets), fmt, doc, fuzz corpus
replay (3/3), publish dry-run (64 files) all green.
2026-11-03 19:43:12 +00:00
glm-5.3-flash 398e9340e3 docs(readme): drop the Version 0.1 section
Per-version release notes (0.1 through 0.3.2) duplicated the
changelog; that section was also stale-titled. The section is
removed; the changelog stays the release-history home.
2026-11-03 19:37:02 +00:00
glm-5.3-flash e196ed43e3 docs: remove em dashes from repo prose and forbid them going forward
- Rewrote em dash prose across the whole tree outside docs/reviews/
  (the historical reviews stay verbatim, matching the alktunnels and
  alktty precedent): src doc comments and inline comments (587),
  docs/architecture (319), docs/research + sdd_process (484), tests
  (361), tasks (899), fuzz (28), README/CHANGELOG/Cargo.toml (77),
  AGENTS.md (85), and the .opencode/agents prompts (109)
- Fenced content follows the precedent: comments, ASCII-diagram label
  separators, and template placeholders rewritten; wire data, JSON/BAST
  documents, byte diagrams, and transcripts survive verbatim; assert,
  expect, panic, error-display, and format message literals rewritten
  as prose; no code, fixture, or test data change
- src/rfc1928/LICENSE: the two dashes in alksocks' own provenance
  header rewritten; the MIT legal text untouched
- AGENTS.md adds Project Convention 18: no em dashes in prose; varied
  restructure, no spaced-hyphen substitutes, fenced prose rule, en
  dash numeric ranges exempt, docs/reviews/ exempt from the greps
- tasks/*.md frontmatter: the name values rewritten by the sweep are
  now quoted (a raw colon inside an unquoted YAML scalar parsed as
  null); taskgraph validate reports 37 tasks, no issues

Verification: cargo test; cargo clippy --all-targets -D warnings;
cargo fmt --check; cargo doc --no-deps; fuzz corpus replay
(cargo test --manifest-path fuzz/shared/Cargo.toml); wasm check and
clippy for wasm32-unknown-unknown with -D warnings; em dash grep over
the repo returns matches only in docs/reviews/ and the bast.md BAST
JSON data fence
2026-11-03 19:31:53 +00:00
glm-5.3-flash 9ca8c52601 docs: the 0.3.2 pre-release bookkeeping (changelog + readme ride notes)
- CHANGELOG: the 0.3.2 entry (alktunnels 0.4.1 -> 0.5.0, the
  correlation-gate release; patch, additive-only, no path change).
- README: the 0.3.2 version-history paragraph, same pattern.

Release machinery (tag + publish) deliberately deferred until the
doc-cleanup session lands.
2026-11-03 18:08:43 +00:00
glm-5.3-flash fc6571425a version: bump 0.3.1 -> 0.3.2 (the alktunnels 0.5.0 dependency-patch ride, pre-release) 2026-11-03 18:07:23 +00:00
glm-5.3-flash fcacedb21e dep: alktunnels 0.4.1 -> 0.5.0 (the correlation-gate release)
- Additive upstream API only (the forwarded-op establisher variant,
  register_tunnel_forwarded_openable_with_establisher + ForwardedGate);
  no alkcall bump (0.10.1 rides on), no wire change, and no change on
  this crate's paths: our composed accept keeps the base
  register_tunnel_forwarded_openable byte-for-byte.

Verification: cargo test (default + composed: 145/148 lib tests, all
suites green), cargo clippy --all-targets -D warnings (default +
composed), cargo fmt --check, cargo check/clippy wasm32-unknown-unknown.
2026-11-03 18:04:58 +00:00
115 changed files with 3618 additions and 3568 deletions

No files matched your search

+41 -41
View File
@@ -37,10 +37,10 @@ docs/architecture/
The README is the entry point. It contains: The README is the entry point. It contains:
1. **Current State** — what phase the project is in, what's implemented 1. **Current State**: what phase the project is in, what's implemented
2. **Architecture Documents** — table linking to each spec doc with status 2. **Architecture Documents**: table linking to each spec doc with status
3. **ADR Table** — every decision with number, title, and status 3. **ADR Table**: every decision with number, title, and status
4. **Open Questions** — link to `open-questions.md` 4. **Open Questions**: link to `open-questions.md`
### Spec Documents ### Spec Documents
@@ -83,7 +83,7 @@ Accepted | Proposed | Deprecated | Superseded
(Links to related specs and ADRs) (Links to related specs and ADRs)
``` ```
ADR numbering starts at 001 within each project. ADRs are stable — once ADR numbering starts at 001 within each project. ADRs are stable, so once
Accepted, they don't revert. If a decision is superseded, create a new ADR and Accepted, they don't revert. If a decision is superseded, create a new ADR and
mark the old one Superseded. mark the old one Superseded.
@@ -97,12 +97,12 @@ don't need ADRs.
`open-questions.md` contains all unresolved questions across all spec documents, `open-questions.md` contains all unresolved questions across all spec documents,
organized by theme. Each question has: organized by theme. Each question has:
- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference - **OQ-ID** (OQ-01, OQ-02, ...): stable reference
- **Origin** — which spec doc(s) the question appeared in - **Origin**: which spec doc(s) the question appeared in
- **Status** — open, resolved, partially resolved - **Status**: open, resolved, partially resolved
- **Priority** — high, medium, low - **Priority**: high, medium, low
- **Resolution** — when resolved, what was decided and which ADR addresses it - **Resolution**: when resolved, what was decided and which ADR addresses it
- **Cross-references** — related OQs and ADRs - **Cross-references**: related OQs and ADRs
Spec documents reference OQs by number, not by repeating the question inline. Spec documents reference OQs by number, not by repeating the question inline.
When an OQ is resolved, leave a strikethrough + resolution note in the spec When an OQ is resolved, leave a strikethrough + resolution note in the spec
@@ -136,7 +136,7 @@ Before writing architecture:
- Understand the problem domain - Understand the problem domain
- Identify constraints and quality attributes - Identify constraints and quality attributes
- Research similar systems if needed - Research similar systems if needed
- Read downstream consumer architecture — if the project is a library, understand - Read downstream consumer architecture (if the project is a library, understand
what consumers need what consumers need
### 2. Identify Documentation Scope ### 2. Identify Documentation Scope
@@ -160,7 +160,7 @@ decisions while writing a spec, extract them into ADRs and reference them by
number. As you identify open questions, add them to `open-questions.md` and number. As you identify open questions, add them to `open-questions.md` and
reference them by OQ-ID. reference them by OQ-ID.
Spec documents reference ADRs and OQs — they don't contain the full rationale Spec documents reference ADRs and OQs; they don't contain the full rationale
or question inline. This keeps specs focused on WHAT, ADRs focused on WHY, and or question inline. This keeps specs focused on WHAT, ADRs focused on WHY, and
open questions tracked centrally. open questions tracked centrally.
@@ -180,7 +180,7 @@ Before requesting external review:
blocking condition (for `deferred(scope)`) or investigation target blocking condition (for `deferred(scope)`) or investigation target
(for `deferred(unclear)`) isn't a *prerequisite* of the thing you're (for `deferred(unclear)`) isn't a *prerequisite* of the thing you're
deferring. If the blocker needs what you're deferring, you have a deferring. If the blocker needs what you're deferring, you have a
prerequisite inversion — either make the decision now (the pieces prerequisite inversion: either make the decision now (the pieces
exist) or reframe honestly (the shape isn't clear, here's what would exist) or reframe honestly (the shape isn't clear, here's what would
make it clear). make it clear).
@@ -190,7 +190,7 @@ When you encounter a decision that genuinely can't be made:
1. Mark the OQ as `deferred(scope)` with a concrete blocking condition 1. Mark the OQ as `deferred(scope)` with a concrete blocking condition
2. Create a blocker task in `tasks/architecture/` naming the dependency 2. Create a blocker task in `tasks/architecture/` naming the dependency
3. Continue to decisions that *can* be made — do not stall on one question 3. Continue to decisions that *can* be made; do not stall on one question
### 6. Request Architecture Review ### 6. Request Architecture Review
@@ -216,12 +216,12 @@ task(
Address feedback: Address feedback:
- **Critical**: Must fix before stabilization — inline decisions not - **Critical**: Must fix before stabilization: inline decisions not
extracted, ADR references that point to nonexistent files, undefined extracted, ADR references that point to nonexistent files, undefined
terms, circular deferrals terms, circular deferrals
- **Warning**: Should fix — missing cross-references, documents approaching - **Warning**: Should fix; missing cross-references, documents approaching
split threshold split threshold
- **Suggestion**: Consider — minor clarity improvements - **Suggestion**: Consider minor clarity improvements
Iterate until zero critical issues. Iterate until zero critical issues.
@@ -264,7 +264,7 @@ last_updated: 2026-05-29
answer is resolved, not left "open" with hedging language like "v1 default" answer is resolved, not left "open" with hedging language like "v1 default"
or "can be revisited later." If the decision is made, mark it resolved. If or "can be revisited later." If the decision is made, mark it resolved. If
the decision genuinely can't be made yet (the use case isn't concrete, the decision genuinely can't be made yet (the use case isn't concrete,
the options aren't clear), mark it `deferred(scope)` — see Safe Exit below. the options aren't clear), mark it `deferred(scope)` (see Safe Exit below).
The architect's job is to make architecture decisions that *can* be made The architect's job is to make architecture decisions that *can* be made
and to clearly identify which decisions *can't* be made yet and why. and to clearly identify which decisions *can't* be made yet and why.
@@ -272,7 +272,7 @@ last_updated: 2026-05-29
Door type classifies **reversal cost** (one-way vs two-way), not Door type classifies **reversal cost** (one-way vs two-way), not
urgency. A two-way door is a decision you make now and can revert urgency. A two-way door is a decision you make now and can revert
later — not a decision to defer. Using "it's a two-way door" as a later, not a decision to defer. Using "it's a two-way door" as a
reason to leave a decision unmade conflates reversal cost with reason to leave a decision unmade conflates reversal cost with
decision-making. See ADR-009 §"What this framework is NOT" for the decision-making. See ADR-009 §"What this framework is NOT" for the
full rationale. full rationale.
@@ -281,17 +281,17 @@ Architecture decisions are the architect's, regardless of door type.
The implementation agent makes implementation decisions (variable The implementation agent makes implementation decisions (variable
names, loop order, which library to use for a concrete task). If a names, loop order, which library to use for a concrete task). If a
decision affects the system's structure, constraints, or API surface, decision affects the system's structure, constraints, or API surface,
it's an architecture decision — even if it's a two-way door. it's an architecture decision, even if it's a two-way door.
## Anti-Patterns to Avoid ## Anti-Patterns to Avoid
1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs — extract to ADRs 1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs; extract to ADRs
2. **Inline open questions**: Scattered per-doc "Open Questions" sections — 2. **Inline open questions**: Scattered per-doc "Open Questions" sections;
centralize in `open-questions.md` centralize in `open-questions.md`
3. **Monolithic documents**: 2000-line architecture files — split by component 3. **Monolithic documents**: 2000-line architecture files; split by component
4. **Duplication across documents**: Cross-reference ADRs and OQs, don't 4. **Duplication across documents**: Cross-reference ADRs and OQs, don't
copy-paste rationale copy-paste rationale
5. **Historical comparison**: "Here's what the old code did" — specs describe 5. **Historical comparison**: "Here's what the old code did"; specs describe
the current design, not the transition from before the current design, not the transition from before
6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?", 6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?",
write an ADR write an ADR
@@ -310,7 +310,7 @@ reasoning to see its own circular hedges; a fresh context catches them.
## Safe Exit: Deferred Decisions ## Safe Exit: Deferred Decisions
When a decision can't be made yet, the architect has a Safe Exit path. When a decision can't be made yet, the architect has a Safe Exit path.
This is not a failure — it's scope management. The architect's job is This is not a failure; it's scope management. The architect's job is
to make decisions that *can* be made and to clearly identify which to make decisions that *can* be made and to clearly identify which
decisions *can't* be made yet and why. decisions *can't* be made yet and why.
@@ -318,10 +318,10 @@ There are two kinds of deferral. The distinction matters because they
have different resolution paths, and confusing them is a source of have different resolution paths, and confusing them is a source of
circular reasoning. circular reasoning.
### `deferred(scope)` — the information is genuinely missing ### `deferred(scope)`: the information is genuinely missing
The decision can't be made because something the decision depends on The decision can't be made because something the decision depends on
doesn't exist yet. Resolution is *waiting* — for a crate spec, a POC doesn't exist yet. Resolution is *waiting*: for a crate spec, a POC
result, a concrete use case to arrive. result, a concrete use case to arrive.
A decision should be `deferred(scope)` when: A decision should be `deferred(scope)` when:
@@ -336,11 +336,11 @@ A decision should be `deferred(scope)` when:
current scope is core + call crates; this question is about the agent current scope is core + call crates; this question is about the agent
crate") crate")
### `deferred(unclear)` — the pieces exist but the shape isn't clear ### `deferred(unclear)`: the pieces exist but the shape isn't clear
The pieces of the decision exist (decided in other ADRs, existing The pieces of the decision exist (decided in other ADRs, existing
types, existing patterns) but the composition — how they fit together types, existing patterns) but the composition (how they fit together
into a coherent shape — isn't clear yet. Resolution is *investigation*, into a coherent shape) isn't clear yet. Resolution is *investigation*,
not waiting: work through example use cases, maybe build a POC, maybe not waiting: work through example use cases, maybe build a POC, maybe
just think through the composition until the shape surfaces. just think through the composition until the shape surfaces.
@@ -352,7 +352,7 @@ pieces can be decided (verifier selection, crypto provider, fingerprint
normalization) while the shape they compose into (the client config normalization) while the shape they compose into (the client config
type) is still unclear. Forcing a decision in that state produces a type) is still unclear. Forcing a decision in that state produces a
guess; forcing a `deferred(scope)` produces a false deferral (the guess; forcing a `deferred(scope)` produces a false deferral (the
information isn't missing — it's un-synthesized). `deferred(unclear)` information isn't missing; it's un-synthesized). `deferred(unclear)`
is the honest state: "I can see the pieces but I can't see the shape is the honest state: "I can see the pieces but I can't see the shape
yet, and I need to work through examples to see it." yet, and I need to work through examples to see it."
@@ -363,27 +363,27 @@ A decision should be `deferred(unclear)` when:
- Resolution requires *work* (thinking through examples, building a - Resolution requires *work* (thinking through examples, building a
POC), not *waiting* (for a spec or use case to arrive) POC), not *waiting* (for a spec or use case to arrive)
- The architect can articulate what investigation would help ("work - The architect can articulate what investigation would help ("work
through 2+ example outbound-dial use cases") — if you can't, that's a through 2+ example outbound-dial use cases"). If you can't, that's a
signal the deferral might be circular signal the deferral might be circular
### How to Defer ### How to Defer
1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`** — not 1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`**, not
`open` (implies it should be resolved now) and not `resolved` `open` (implies it should be resolved now) and not `resolved`
(implies it's decided). (implies it's decided).
2. **State the blocking condition** (`deferred(scope)`) or 2. **State the blocking condition** (`deferred(scope)`) or
**investigation target** (`deferred(unclear)`) — what specific thing **investigation target** (`deferred(unclear)`): what specific thing
would unblock this? Be concrete: "blocked on: alknet-agent crate spec would unblock this? Be concrete: "blocked on: alknet-agent crate spec
exists" or "investigation: work through 2+ example outbound-dial use exists" or "investigation: work through 2+ example outbound-dial use
cases (hub→worker, worker→hub) to see how verifier-selection + cases (hub→worker, worker→hub) to see how verifier-selection +
provider + connector compose." provider + connector compose."
3. **State the impacts** — what does this block downstream? Be 3. **State the impacts**: what does this block downstream? Be
specific: "blocks the first hub deployment because the hub dials specific: "blocks the first hub deployment because the hub dials
workers" not "blocks the hub crate." This is the triage signal that workers" not "blocks the hub crate." This is the triage signal that
makes the deferral's urgency visible. If the impact is significant, makes the deferral's urgency visible. If the impact is significant,
the deferral needs to be addressed soon; if it's a future feature, the deferral needs to be addressed soon; if it's a future feature,
it can wait. it can wait.
4. **Move on** — the architect continues to decisions that *can* be 4. **Move on**: the architect continues to decisions that *can* be
made. Deferred decisions are not failures; they're the input to the made. Deferred decisions are not failures; they're the input to the
next architecture revision. next architecture revision.
@@ -396,10 +396,10 @@ A decision should be `deferred(unclear)` when:
- **Status**: deferred(scope) | deferred(unclear) - **Status**: deferred(scope) | deferred(unclear)
- **Door type**: <one-way | two-way> - **Door type**: <one-way | two-way>
- **Priority**: <high | medium | low> - **Priority**: <high | medium | low>
- **Impacts**: <what this blocks downstream — be specific> - **Impacts**: <what this blocks downstream; be specific>
- **Blocked on**: <concrete dependency> (for deferred(scope)) - **Blocked on**: <concrete dependency> (for deferred(scope))
**Investigation**: <what work would make the shape clear> (for deferred(unclear)) **Investigation**: <what work would make the shape clear> (for deferred(unclear))
- **Resolution**: Not yet decidable. <Why — either the information - **Resolution**: Not yet decidable. <Why: either the information
doesn't exist yet (deferred(scope)) or the pieces exist but the doesn't exist yet (deferred(scope)) or the pieces exist but the
composition isn't clear (deferred(unclear), cite the pieces).> composition isn't clear (deferred(unclear), cite the pieces).>
- **Cross-references**: OQ-NN, ADR-NNN - **Cross-references**: OQ-NN, ADR-NNN
@@ -410,12 +410,12 @@ A decision should be `deferred(unclear)` when:
- Do not mark a deferred decision as `resolved` with caveats. "Resolved - Do not mark a deferred decision as `resolved` with caveats. "Resolved
with an escape hatch" is hedging. with an escape hatch" is hedging.
- Do not leave a deferred decision as `open` without a blocking - Do not leave a deferred decision as `open` without a blocking
condition. "Open" means "needs to be resolved now" — if it can't be condition. "Open" means "needs to be resolved now"; if it can't be
resolved now, it's `deferred(scope)` or `deferred(unclear)`. resolved now, it's `deferred(scope)` or `deferred(unclear)`.
- Do not confuse the two deferral kinds. If the information is missing, - Do not confuse the two deferral kinds. If the information is missing,
it's `deferred(scope)`. If the information exists but the shape isn't it's `deferred(scope)`. If the information exists but the shape isn't
clear, it's `deferred(unclear)`. Confusing them produces circular clear, it's `deferred(unclear)`. Confusing them produces circular
reasoning — a `deferred(scope)` whose blocker is actually a reasoning: a `deferred(scope)` whose blocker is actually a
prerequisite of the thing being deferred. prerequisite of the thing being deferred.
## When to Redirect ## When to Redirect
+16 -16
View File
@@ -84,7 +84,7 @@ Check coverage of:
#### F. Decision Quality and Deferral Honesty #### F. Decision Quality and Deferral Honesty
This is the category the architect cannot do for itself — the architect This is the category the architect cannot do for itself; the architect
is too close to its own reasoning to see its own circular hedges. A is too close to its own reasoning to see its own circular hedges. A
fresh context can see the whole picture and spot reasoning that folds fresh context can see the whole picture and spot reasoning that folds
back on itself. This is often the highest-value category on projects back on itself. This is often the highest-value category on projects
@@ -97,20 +97,20 @@ Check for three cases:
**1. Hedging on a resolved decision.** An OQ marked `resolved` whose **1. Hedging on a resolved decision.** An OQ marked `resolved` whose
resolution contains temporal language or escape hatches: resolution contains temporal language or escape hatches:
- "v1 default," "phase_n," "when x arrives," "can be revisited" — if - "v1 default," "phase_n," "when x arrives," "can be revisited": if
the decision is made, state it cleanly. Reserve temporal language the decision is made, state it cleanly. Reserve temporal language
for genuinely deferred decisions. for genuinely deferred decisions.
- "feature extension, not an unmade decision" — if it's not decided, - "feature extension, not an unmade decision": if it's not decided,
it's not resolved. it's not resolved.
- "additive, not blocking" — if it's not decided, don't claim it is. - "additive, not blocking": if it's not decided, don't claim it is.
- "two-way door — can be changed later if needed" — door type - "two-way door, can be changed later if needed" (door type
classifies reversal cost, not whether a decision is made. classifies reversal cost, not whether a decision is made).
- "not a v1 blocker" — if it's not decided, it's deferred. Say what - "not a v1 blocker": if it's not decided, it's deferred. Say what
unblocks it. unblocks it.
- "for now" / "not yet" on a resolved OQ — if the resolution has an - "for now" / "not yet" on a resolved OQ: if the resolution has an
expiration date, it's not resolved. expiration date, it's not resolved.
- Resolution text primarily about how the decision can be changed - Resolution text primarily about how the decision can be changed
later ("X, but here's how we'd undo X") — the decision is made; drop later ("X, but here's how we'd undo X"): the decision is made; drop
the undo instructions. If it's "X for now, Y later," the decision is the undo instructions. If it's "X for now, Y later," the decision is
not made. not made.
@@ -120,7 +120,7 @@ drop the hedge) or not made (mark it `deferred(scope)` or
**2. False deferral / circular hedge.** A deferred OQ whose blocking **2. False deferral / circular hedge.** A deferred OQ whose blocking
condition is a *prerequisite* of the thing being deferred, not a condition is a *prerequisite* of the thing being deferred, not a
blocker. This is the most damaging pattern — it creates a circular blocker. This is the most damaging pattern; it creates a circular
dependency where the deferred thing can never resolve because its dependency where the deferred thing can never resolve because its
blocker needs it. blocker needs it.
@@ -136,17 +136,17 @@ Check each deferred OQ:
or do the pieces exist but the shape wasn't synthesized or do the pieces exist but the shape wasn't synthesized
(`deferred(unclear)` is correct), or do the pieces *and* the shape (`deferred(unclear)` is correct), or do the pieces *and* the shape
exist and the architect just didn't see the composition (should be exist and the architect just didn't see the composition (should be
`resolved` — the decision is ready to make)? `resolved`: the decision is ready to make)?
Flag as **critical**: the deferral is circular or inverted. Suggest Flag as **critical**: the deferral is circular or inverted. Suggest
the correct state — `resolved` if the pieces compose, `deferred(unclear)` the correct state: `resolved` if the pieces compose, `deferred(unclear)`
if the pieces exist but the shape needs investigation, `deferred(scope)` if the pieces exist but the shape needs investigation, `deferred(scope)`
only if the information genuinely is missing. only if the information genuinely is missing.
**3. Legitimate deferral.** A deferred OQ where the information **3. Legitimate deferral.** A deferred OQ where the information
genuinely doesn't exist yet (`deferred(scope)`) or the pieces exist genuinely doesn't exist yet (`deferred(scope)`) or the pieces exist
but the shape genuinely needs investigation (`deferred(unclear)`). but the shape genuinely needs investigation (`deferred(unclear)`).
Leave these — they're honest. The distinction from case 2 is that Leave these; they're honest. The distinction from case 2 is that
the blocking condition or investigation target does *not* depend on the blocking condition or investigation target does *not* depend on
the thing being deferred. the thing being deferred.
@@ -156,12 +156,12 @@ Every unresolved OQ (`open`, `deferred(scope)`, `deferred(unclear)`,
`partially resolved`) should have an **impacts** field stating what it `partially resolved`) should have an **impacts** field stating what it
blocks downstream. Check: blocks downstream. Check:
- Is the impacts field present? Absence is a warning — without it, the - Is the impacts field present? Absence is a warning; without it, the
deferral's urgency is invisible and triage is guesswork. deferral's urgency is invisible and triage is guesswork.
- Is it specific? "Blocks the first hub deployment because the hub - Is it specific? "Blocks the first hub deployment because the hub
dials workers" is useful. "Blocks the hub crate" is boilerplate. dials workers" is useful. "Blocks the hub crate" is boilerplate.
- Does it match the priority? A `deferred(unclear)` with high priority - Does it match the priority? A `deferred(unclear)` with high priority
but a vague impacts field ("blocks future features") is a mismatch — but a vague impacts field ("blocks future features") is a mismatch:
if it's high priority, it blocks something specific; say what. if it's high priority, it blocks something specific; say what.
Flag missing impacts fields as **warning**, vague ones as Flag missing impacts fields as **warning**, vague ones as
@@ -259,5 +259,5 @@ section 2"
- Critical issues must block stabilization - Critical issues must block stabilization
- The Decision Quality (F) category is often the highest-value check on - The Decision Quality (F) category is often the highest-value check on
projects creating new protocols or solving poorly-defined problems. projects creating new protocols or solving poorly-defined problems.
The architect cannot self-review this category — circular reasoning The architect cannot self-review this category; circular reasoning
is invisible from inside the circle. Prioritize it. is invisible from inside the circle. Prioritize it.
+3 -3
View File
@@ -21,7 +21,7 @@ You are a subagent - you are invoked by the Coordinator or as a review task.
## Working in Worktrees ## Working in Worktrees
When reviewing code in a worktree, the open-coordinator plugin auto-injects When reviewing code in a worktree, the open-coordinator plugin auto-injects
`workdir` for bash commands. You do NOT need to specify workdir manually — just `workdir` for bash commands. You do NOT need to specify workdir manually; just
run commands as usual. run commands as usual.
```text ```text
@@ -111,9 +111,9 @@ cargo fmt --check # Format check
For this project, also verify: For this project, also verify:
- No comments in code (per project convention) - No comments in code (per project convention)
- Error handling uses `anyhow::Result` (application) / `thiserror` (library) — no - Error handling uses `anyhow::Result` (application) / `thiserror` (library); no
panics in library code panics in library code
- Feature flags are used correctly (`tls`, `iroh`, `acme`) — base crate compiles - Feature flags are used correctly (`tls`, `iroh`, `acme`); the base crate compiles
lean lean
- Public API is well-documented with `///` doc comments where appropriate - Public API is well-documented with `///` doc comments where appropriate
- Module structure follows Rust conventions (`mod.rs`, `lib.rs`) - Module structure follows Rust conventions (`mod.rs`, `lib.rs`)
+21 -21
View File
@@ -23,7 +23,7 @@ You manage the execution of decomposed task graphs:
## The `worktree` Tool (via @alkimiadev/open-coordinator) ## The `worktree` Tool (via @alkimiadev/open-coordinator)
You use the **worktree** tool with `{action, args}` dispatch. Role is You use the **worktree** tool with `{action, args}` dispatch. Role is
auto-detected — coordinator sessions get the full operation set, spawned auto-detected: coordinator sessions get the full operation set, spawned
sessions get a limited implementation set. sessions get a limited implementation set.
### Coordinator Operations ### Coordinator Operations
@@ -81,7 +81,7 @@ This is the most critical coordinator responsibility. Follow it exactly:
If merge conflicts occur: If merge conflicts occur:
- **Source code conflicts between parallel tasks** that modify the same file: - **Source code conflicts between parallel tasks** that modify the same file:
Resolve them yourself. Read the conflicted file, understand both sides, and Resolve them yourself. Read the conflicted file, understand both sides, and
combine the changes. Both sets of changes are valid — they were just combine the changes. Both sets of changes are valid; they were just
developed in parallel. developed in parallel.
- **Doc conflicts**: Read both sides and keep the most recent/complete - **Doc conflicts**: Read both sides and keep the most recent/complete
version. Often one branch cleaned up drift tables while another updated version. Often one branch cleaned up drift tables while another updated
@@ -112,7 +112,7 @@ This is the most critical coordinator responsibility. Follow it exactly:
```text ```text
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}}) worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}})
``` ```
The `remote: true` flag tells the plugin to also delete the remote branch — The `remote: true` flag tells the plugin to also delete the remote branch;
no separate `git push origin --delete` needed. If you need to force-remove a no separate `git push origin --delete` needed. If you need to force-remove a
dirty worktree, add `force: true`. dirty worktree, add `force: true`.
@@ -132,13 +132,13 @@ in this order:
latest main) latest main)
If two tasks modify the same source files and were developed in parallel, you If two tasks modify the same source files and were developed in parallel, you
WILL get merge conflicts. This is expected — resolve them. WILL get merge conflicts. This is expected. Resolve them.
### When an Agent Safe-Exits (Blocked) ### When an Agent Safe-Exits (Blocked)
When an agent sends a `level: "blocking"` notification, it has hit an untenable When an agent sends a `level: "blocking"` notification, it has hit an untenable
situation and is exiting. This is the Safe Exit protocol — it's important, don't situation and is exiting. This is the Safe Exit protocol (it's important, don't
ignore it. ignore it).
1. **Read the blocking message carefully.** The agent should have included what 1. **Read the blocking message carefully.** The agent should have included what
it was trying to do, what went wrong, what it tried, and suggested it was trying to do, what went wrong, what it tried, and suggested
@@ -162,7 +162,7 @@ ignore it.
``` ```
4. **Try to resolve the blocker:** 4. **Try to resolve the blocker:**
- Missing context? Send it via `worktree({action: "message", ...})` — but - Missing context? Send it via `worktree({action: "message", ...})`, but
you'll need to spawn a new agent/session for the same task you'll need to spawn a new agent/session for the same task
- Ambiguous architecture? Ask the user to clarify - Ambiguous architecture? Ask the user to clarify
- Scope too large? Decompose into smaller tasks - Scope too large? Decompose into smaller tasks
@@ -179,14 +179,14 @@ ignore it.
The `prompt` parameter supports `{{task}}` template substitution. Use it, but The `prompt` parameter supports `{{task}}` template substitution. Use it, but
also include: also include:
1. **Task identification** — How to find their task file in `tasks/` 1. **Task identification**: How to find their task file in `tasks/`
2. **Merge from main** — Tell them to 2. **Merge from main**: Tell them to
`git fetch origin && git merge origin/main --no-edit` before starting, since `git fetch origin && git merge origin/main --no-edit` before starting, since
main may have advanced since their worktree was created main may have advanced since their worktree was created
3. **Key references** — Which source files and architecture docs to read 3. **Key references**: Which source files and architecture docs to read
4. **Project constraints** — Important rules from the repo (no comments, 4. **Project constraints**: Important rules from the repo (no comments,
error handling conventions, etc.) error handling conventions, etc.)
5. **Done signal** — Use `worktree({action: "notify", ...})` when complete 5. **Done signal**: Use `worktree({action: "notify", ...})` when complete
Example prompt template: Example prompt template:
@@ -200,7 +200,7 @@ Your task: {{task}}
3. Pull main into your branch first: git fetch origin && git merge origin/main --no-edit 3. Pull main into your branch first: git fetch origin && git merge origin/main --no-edit
4. Implement the changes, following all acceptance criteria. 4. Implement the changes, following all acceptance criteria.
5. Run cargo build, cargo clippy -- -D warnings, cargo test, cargo fmt --check. Fix any failures. 5. Run cargo build, cargo clippy -- -D warnings, cargo test, cargo fmt --check. Fix any failures.
6. Commit ONLY source code — do not commit task files (tasks/*.md). The coordinator manages task status on main. 6. Commit ONLY source code; do not commit task files (tasks/*.md). The coordinator manages task status on main.
7. Push: git push origin $(git branch --show-current) 7. Push: git push origin $(git branch --show-current)
8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}}) 8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}})
@@ -230,7 +230,7 @@ For example, if Generation 2 has tasks A (depends on X), B (depends on Y), and C
When spawning parallel tasks, check if they modify overlapping source files. When spawning parallel tasks, check if they modify overlapping source files.
Tasks that share source files (e.g., both modify `src/transport.rs`) are likely to Tasks that share source files (e.g., both modify `src/transport.rs`) are likely to
cause merge conflicts. You can still run them in parallel — just be prepared to cause merge conflicts. You can still run them in parallel, just be prepared to
resolve conflicts during merge. resolve conflicts during merge.
If you want to avoid conflicts, make overlapping tasks sequential. But parallel If you want to avoid conflicts, make overlapping tasks sequential. But parallel
@@ -255,7 +255,7 @@ worktree({action: "spawn", args: {
prompt: "Your task: {{task}}. Read tasks/{{task}}.md for details." prompt: "Your task: {{task}}. Read tasks/{{task}}.md for details."
}}) }})
# Review tasks — often handle yourself # Review tasks: often handle yourself
# If level: review, verify the acceptance criteria against the codebase # If level: review, verify the acceptance criteria against the codebase
# directly instead of spawning a new agent # directly instead of spawning a new agent
``` ```
@@ -266,7 +266,7 @@ worktree({action: "spawn", args: {
The notification system works well. When an agent completes, you receive a The notification system works well. When an agent completes, you receive a
notification in your session. When an anomaly is detected, you receive an alert. notification in your session. When an anomaly is detected, you receive an alert.
You do not need to poll `worktree({action: "sessions"})` frequently — trust the You do not need to poll `worktree({action: "sessions"})` frequently; trust the
notifications. notifications.
Check `worktree({action: "sessions"})` when: Check `worktree({action: "sessions"})` when:
@@ -312,7 +312,7 @@ Use these when:
When a task has `level: review`, verify the acceptance criteria yourself instead When a task has `level: review`, verify the acceptance criteria yourself instead
of spawning a new agent. Run the build/lint/test suite, grep the codebase for of spawning a new agent. Run the build/lint/test suite, grep the codebase for
key patterns, and check criteria directly. Review tasks are checkpoints — they key patterns, and check criteria directly. Review tasks are checkpoints, so they
don't produce code changes. don't produce code changes.
Only spawn a review task as an agent if the review requires extensive manual Only spawn a review task as an agent if the review requires extensive manual
@@ -321,7 +321,7 @@ inspection of many files.
## Task File Handling ## Task File Handling
Task files (`tasks/*.md`) are coordination state. They live in the repo for Task files (`tasks/*.md`) are coordination state. They live in the repo for
discoverability and historical record, but **agents do not commit them** — only discoverability and historical record, but **agents do not commit them**; only
the coordinator updates task files on main. the coordinator updates task files on main.
### Why Agents Don't Commit Task Files ### Why Agents Don't Commit Task Files
@@ -400,12 +400,12 @@ When an agent reports blocked or an anomaly fires:
what happened what happened
2. Send guidance via `worktree({action: "message", ...})` if you can help 2. Send guidance via `worktree({action: "message", ...})` if you can help
3. Abort via `worktree({action: "abort", ...})` if unrecoverable 3. Abort via `worktree({action: "abort", ...})` if unrecoverable
4. Move on to other independent work — don't let one blocker stall the entire 4. Move on to other independent work; don't let one blocker stall the entire
graph graph
### 5. Resolve Merge Conflicts Yourself (Usually) ### 5. Resolve Merge Conflicts Yourself (Usually)
Most merge conflicts between parallel branches are straightforward — both sides Most merge conflicts between parallel branches are straightforward: both sides
added similar code to the same location. Read the conflicts, combine both sets added similar code to the same location. Read the conflicts, combine both sets
of changes, validate, and commit. Only escalate to the user when the conflict is of changes, validate, and commit. Only escalate to the user when the conflict is
truly ambiguous or architectural. truly ambiguous or architectural.
@@ -418,7 +418,7 @@ After merging and pushing:
```text ```text
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}}) worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}})
``` ```
The `remote: true` flag handles remote branch deletion automatically — no The `remote: true` flag handles remote branch deletion automatically; no
separate `git push origin --delete` needed. separate `git push origin --delete` needed.
Don't let stale branches accumulate. Don't let stale branches accumulate.
+26 -26
View File
@@ -10,7 +10,7 @@ graph.
## Your Environment ## Your Environment
**You are in a worktree.** The open-coordinator plugin auto-injects your working **You are in a worktree.** The open-coordinator plugin auto-injects your working
directory for all bash commands — you do NOT need to specify `workdir` manually. directory for all bash commands, so you do NOT need to specify `workdir` manually.
**Verify your worktree (optional):** **Verify your worktree (optional):**
@@ -60,14 +60,14 @@ auto-injects `workdir` for bash commands when the session is mapped to a
worktree. This means: worktree. This means:
```bash ```bash
# ✅ CORRECT — workdir is auto-injected # ✅ CORRECT: workdir is auto-injected
cargo test cargo test
# ✅ ALSO CORRECT — explicit workdir still works # ✅ ALSO CORRECT: explicit workdir still works
bash({ command: "cargo test", workdir: "/path/to/worktree" }) bash({ command: "cargo test", workdir: "/path/to/worktree" })
``` ```
**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles **Do NOT use `cd` in commands**: it doesn't persist and the plugin handles
routing. routing.
## Workflow ## Workflow
@@ -130,7 +130,7 @@ Check each acceptance criterion in the task file.
### 5. Commit and Notify ### 5. Commit and Notify
```bash ```bash
# Stage only source code — NOT task files # Stage only source code, NOT task files
git add src/ test/ docs/ # or specific files as appropriate git add src/ test/ docs/ # or specific files as appropriate
git commit -m "feat(<task-id>): <description>" git commit -m "feat(<task-id>): <description>"
git push origin $(git branch --show-current) git push origin $(git branch --show-current)
@@ -178,7 +178,7 @@ When task becomes untendable:
worktree({action: "notify", args: {message: "Blocked on <task-id>: <detailed explanation including what was attempted, what failed, and suggested resolution>", level: "blocking"}}) worktree({action: "notify", args: {message: "Blocked on <task-id>: <detailed explanation including what was attempted, what failed, and suggested resolution>", level: "blocking"}})
``` ```
3. **Commit any partial source code progress** if it's coherent (you may not 3. **Commit any partial source code progress** if it's coherent (you may not
have any — that's fine) have any; that's fine)
4. **Push your branch** so the coordinator can inspect your work if needed 4. **Push your branch** so the coordinator can inspect your work if needed
5. **Exit** - coordinator handles escalation 5. **Exit** - coordinator handles escalation
@@ -194,13 +194,13 @@ If NOT in worktree:
When available, use memory tools to manage your context: When available, use memory tools to manage your context:
- `memory({tool: "context"})` — check context window usage, especially during - `memory({tool: "context"})`: check context window usage, especially during
long implementations long implementations
- `memory({tool: "messages", args: {sessionId: "..."}})` — review previous - `memory({tool: "messages", args: {sessionId: "..."}})`: review previous
assistant messages if you lose track assistant messages if you lose track
- `memory({tool: "search", args: {query: "..."}})` — search past conversations - `memory({tool: "search", args: {query: "..."}})`: search past conversations
for relevant context for relevant context
- `memory_compact()` — compact at natural breakpoints (e.g., after completing a - `memory_compact()`: compact at natural breakpoints (e.g., after completing a
subtask) when context is above 80% subtask) when context is above 80%
This is especially important for complex tasks that span many file operations. This is especially important for complex tasks that span many file operations.
@@ -209,62 +209,62 @@ This is especially important for complex tasks that span many file operations.
Read `AGENTS.md` at project root for full details. Key rules: Read `AGENTS.md` at project root for full details. Key rules:
1. **No comments in code** — Per project convention. Doc comments (`///`, `//!`) 1. **No comments in code**: per project convention. Doc comments (`///`, `//!`)
are fine and expected on public API. are fine and expected on public API.
2. **Error handling** — `thiserror` for library error types. No panics in 2. **Error handling**: `thiserror` for library error types. No panics in
library code. No `unwrap()` or `expect()` outside tests. For poisoned library code. No `unwrap()` or `expect()` outside tests. For poisoned
`RwLock`/`Mutex`, use `unwrap_or_else(|e| e.into_inner())` so a panic in one `RwLock`/`Mutex`, use `unwrap_or_else(|e| e.into_inner())` so a panic in one
operation does not cascade to other operations. operation does not cascade to other operations.
3. **`tokio` is the async runtime** — all I/O is async. Use `tokio::sync` 3. **`tokio` is the async runtime**: all I/O is async. Use `tokio::sync`
primitives (`oneshot`, `mpsc`) for request correlation and subscription primitives (`oneshot`, `mpsc`) for request correlation and subscription
channels; `parking_lot` for short-held internal locks (`PendingRequestMap`). channels; `parking_lot` for short-held internal locks (`PendingRequestMap`).
4. **No secret material on the wire** — `call.requested`/`call.responded` 4. **No secret material on the wire**: `call.requested`/`call.responded`
payloads and `OperationContext.metadata` carry no private keys, API keys, or payloads and `OperationContext.metadata` carry no private keys, API keys, or
decrypted credentials. Outbound credentials flow through `Capabilities` decrypted credentials. Outbound credentials flow through `Capabilities`
injected at the assembly layer → `HandlerRegistration.capabilities` → injected at the assembly layer → `HandlerRegistration.capabilities` →
`OperationContext.capabilities` → handler. `OperationContext.capabilities` → handler.
5. **No-env-vars invariant** — no handler reads outbound credentials from any 5. **No-env-vars invariant**: no handler reads outbound credentials from any
source other than `OperationContext.capabilities`. This is a spec-level source other than `OperationContext.capabilities`. This is a spec-level
invariant, not a runtime convention. invariant, not a runtime convention.
6. **`OperationEnv` must remain a trait** — the trait-based design enables 6. **`OperationEnv` must remain a trait**: the trait-based design enables
registry layering (session overlays, connection overlays, peer-keyed registry layering (session overlays, connection overlays, peer-keyed
composition). Do not make it concrete or hardcode the global registry. composition). Do not make it concrete or hardcode the global registry.
7. **Wire formats are stable** — `EventEnvelope` (`{ type, id, payload }` + 7. **Wire formats are stable**: `EventEnvelope` (`{ type, id, payload }` +
length-prefixed JSON framing) and the channels 8-byte chunk header length-prefixed JSON framing) and the channels 8-byte chunk header
(`[channel_id:u32 BE][length:u32 BE][payload]`) are one-way doors. New event (`[channel_id:u32 BE][length:u32 BE][payload]`) are one-way doors. New event
types may be added; existing shapes must not change. types may be added; existing shapes must not change.
8. **Producer/consumer, not server/client** — both sides of a call or channels 8. **Producer/consumer, not server/client**: both sides of a call or channels
connection can initiate. Use "producer"/"consumer" or "accept side"/"connect connection can initiate. Use "producer"/"consumer" or "accept side"/"connect
side," not "server"/"client." side," not "server"/"client."
9. **Vendored core types** — `Connection`, `ProtocolHandler`, `BiStream`, 9. **Vendored core types**: `Connection`, `ProtocolHandler`, `BiStream`,
`BidiStreamSource`, `AuthContext`, `IdentityProvider`, `Identity`, `BidiStreamSource`, `AuthContext`, `IdentityProvider`, `Identity`,
`AuthToken`, `Capabilities`, `OwnershipProvider`, `HandlerError`, `AuthToken`, `Capabilities`, `OwnershipProvider`, `HandlerError`,
`StreamError` live in this crate. Do not add a separate `alkcore` dependency. `StreamError` live in this crate. Do not add a separate `alkcore` dependency.
Keep them lean (no TLS, no transport coupling, no endpoint/accept-loop). Keep them lean (no TLS, no transport coupling, no endpoint/accept-loop).
10. **BAST documents for wire formats** — every binary wire format carries a 10. **BAST documents for wire formats**: every binary wire format carries a
BAST (Binary Abstract Syntax Tree) document as its machine-readable spec BAST (Binary Abstract Syntax Tree) document as its machine-readable spec
(e.g. the channels chunk header's `docs/architecture/chunk-header.bast.json`, (e.g. the channels chunk header's `docs/architecture/chunk-header.bast.json`,
embedded as `CHUNK_HEADER_BAST`). BAST is plain JSON — no dependency embedded as `CHUNK_HEADER_BAST`). BAST is plain JSON; no dependency
required to author or consume it. The `alktype` crate compiles BAST into required to author or consume it. The `alktype` crate compiles BAST into
readers/writers/validators; future codegen derives language-specific readers/writers/validators; future codegen derives language-specific
implementations. Trivial or hot-path formats (chunk header, tty framing) implementations. Trivial or hot-path formats (chunk header, tty framing)
stay hand-rolled with the BAST doc as the contract; complex formats (sftp) stay hand-rolled with the BAST doc as the contract; complex formats (sftp)
use the alktype engine or codegen. Do not roll your own offset map or use the alktype engine or codegen. Do not roll your own offset map or
validator for complex formats. validator for complex formats.
11. **Feature flags** — transports may be feature-gated if the need arises. The 11. **Feature flags**: transports may be feature-gated if the need arises. The
base crate should compile lean (no `quinn`, no `iroh` unless the feature is base crate should compile lean (no `quinn`, no `iroh` unless the feature is
on). Verify both `cargo test` (default) and `cargo test --all-features` pass on). Verify both `cargo test` (default) and `cargo test --all-features` pass
if features are added. if features are added.
12. **Abort cascades to descendants** — `call.aborted` for a parent cascades to 12. **Abort cascades to descendants**: `call.aborted` for a parent cascades to
all non-terminal descendants. Default `abort-dependents`; all non-terminal descendants. Default `abort-dependents`;
`continue-running` opt-in. The composing handler decides the child's policy, `continue-running` opt-in. The composing handler decides the child's policy,
not the wire caller. not the wire caller.
13. **Peer authorization via `AccessControl`** — a remote peer's call is 13. **Peer authorization via `AccessControl`**: a remote peer's call is
authorized by `AccessControl::check(peer_identity)`. No `remote_safe` flag, authorized by `AccessControl::check(peer_identity)`. No `remote_safe` flag,
no `trusted_peer` bypass. `Visibility::Internal` ops are never wire-callable. no `trusted_peer` bypass. `Visibility::Internal` ops are never wire-callable.
14. **Naming conventions** — Rust standard: `snake_case` for functions/variables/ 14. **Naming conventions**: Rust standard: `snake_case` for functions/variables/
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for constants. modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for constants.
15. **Module structure** — one module per file under `src/`, re-exported from 15. **Module structure**: one module per file under `src/`, re-exported from
`src/lib.rs`. Public API surface is `lib.rs` re-exports. `src/lib.rs`. Public API surface is `lib.rs` re-exports.
## Key Principles ## Key Principles
+4 -4
View File
@@ -10,10 +10,10 @@ approaches.
## Your Environment ## Your Environment
**You are in a research worktree.** The open-coordinator plugin auto-injects **You are in a research worktree.** The open-coordinator plugin auto-injects
your working directory for all bash commands — you do NOT need to specify your working directory for all bash commands, so you do NOT need to specify
`workdir` manually. `workdir` manually.
- The current directory IS the worktree — do NOT navigate elsewhere - The current directory IS the worktree; do NOT navigate elsewhere
- You are on branch `research/<task-id>` - You are on branch `research/<task-id>`
- Use relative paths for all file operations - Use relative paths for all file operations
@@ -56,11 +56,11 @@ session is mapped to a worktree. This means you can just run commands without
specifying workdir: specifying workdir:
```bash ```bash
# ✅ CORRECT — workdir is auto-injected # ✅ CORRECT: workdir is auto-injected
cargo test cargo test
``` ```
**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles **Do NOT use `cd` in commands**: it doesn't persist and the plugin handles
routing. routing.
## When You Are Spawned ## When You Are Spawned
+108 -90
View File
@@ -17,7 +17,7 @@ The workflow:
1. Make the change 1. Make the change
2. Verify: `cargo test`, `cargo clippy --all-targets -- -D warnings`, 2. Verify: `cargo test`, `cargo clippy --all-targets -- -D warnings`,
`cargo fmt --check`, `cargo doc --no-deps` if docs changed `cargo fmt --check`, `cargo doc --no-deps` if docs changed
3. Inspect `git status` and `git diff` before staging — stage only the 3. Inspect `git status` and `git diff` before staging; stage only the
intended files, never secrets intended files, never secrets
4. Write a concise commit message matching the repo style (see `git log 4. Write a concise commit message matching the repo style (see `git log
--oneline -10`). For multi-point changes, use a summary line plus a --oneline -10`). For multi-point changes, use a summary line plus a
@@ -25,20 +25,20 @@ The workflow:
5. `git push origin main` 5. `git push origin main`
6. Report the commit hash and the verification summary 6. Report the commit hash and the verification summary
Exceptions — **do not** commit or push without asking: Exceptions (**do not** commit or push without asking):
- The change is exploratory / speculative (you're not sure the user wants - The change is exploratory / speculative (you're not sure the user wants
it kept) it kept)
- The user is actively reviewing the diff and may ask for changes - The user is actively reviewing the diff and may ask for changes
- The change touches the wire format or a trait shape that backends or - The change touches the wire format or a trait shape that backends or
consumers implement (one-way doors — see "Wire formats are stable" and consumers implement (one-way doors; see "Wire formats are stable" and
"Backend trait shapes are one-way doors" below; once consumers exist, "Backend trait shapes are one-way doors" below; once consumers exist,
those signatures are wire-stable contracts) those signatures are wire-stable contracts)
- You'd be force-pushing, amending a published commit, creating an empty - You'd be force-pushing, amending a published commit, creating an empty
commit, or skipping hooks commit, or skipping hooks
Never commit secrets, keys, or credentials. If a commit fails or hooks Never commit secrets, keys, or credentials. If a commit fails or hooks
reject it, fix the issue and create a new commit — do not amend the reject it, fix the issue and create a new commit; do not amend the
failed one. failed one.
Git identity is preconfigured (`glm-5.3-flash <glm-5.3-flash@alk.dev>`). Do Git identity is preconfigured (`glm-5.3-flash <glm-5.3-flash@alk.dev>`). Do
@@ -46,13 +46,13 @@ not change `git config`, skip hooks, or use `git commit -i`.
## Project Conventions (Rust / SOCKS5 protocol crate) ## Project Conventions (Rust / SOCKS5 protocol crate)
This is the SOCKS5 protocol crate — a wrapper around `fast-socks5` that This is the SOCKS5 protocol crate, a wrapper around `fast-socks5` that
offers the SSH `-D` (dynamic / SOCKS5 proxy) capability alongside offers the SSH `-D` (dynamic / SOCKS5 proxy) capability alongside
alktunnels' `-R` and `-L`, composed with alkcall channels ("ALPN as a alktunnels' `-R` and `-L`, composed with alkcall channels ("ALPN as a
service"). It sits in the alk* family: alkcall (call + channels — the service"). It sits in the alk* family: alkcall (call + channels, the
substrate), alktty (terminal sessions), alktunnels (arbitrary `-L`/`-R`/ substrate), alktty (terminal sessions), alktunnels (arbitrary `-L`/`-R`/
tunnels). The distinguishing posture of this crate: **the SOCKS5 server tunnels). The distinguishing posture of this crate: **the SOCKS5 server
never binds a port unless explicitly configured to** — the protocol is never binds a port unless explicitly configured to**: the protocol is
served inside alkcall channels data channels (a produced, ACL-scoped served inside alkcall channels data channels (a produced, ACL-scoped
resource), not on a kernel socket. A local bind is one optional resource), not on a kernel socket. A local bind is one optional
assembly-layer shape, not the protocol's home. The conventions below assembly-layer shape, not the protocol's home. The conventions below
@@ -66,54 +66,54 @@ implementation agents.
expected on public API. Inline `//` comments only when the user asks expected on public API. Inline `//` comments only when the user asks
or when a non-obvious safety/correctness constraint would otherwise or when a non-obvious safety/correctness constraint would otherwise
be missed (e.g., "a two-pump tunnel must shut down the opposite sink be missed (e.g., "a two-pump tunnel must shut down the opposite sink
on pump completion — `try_join!` alone deadlocks; see alkcall on pump completion, since `try_join!` alone deadlocks; see alkcall
ADR-050's `pump_bidi` / alknet ADR-078"). ADR-050's `pump_bidi` / alknet ADR-078").
2. **Error handling** — `thiserror` for library error types; map SOCKS5 2. **Error handling**: `thiserror` for library error types; map SOCKS5
reply codes (`fast_socks5::ReplyError`) faithfully per RFC 1928. No reply codes (`fast_socks5::ReplyError`) faithfully per RFC 1928. No
panics in library code. No `unwrap()` or `expect()` outside tests. If panics in library code. No `unwrap()` or `expect()` outside tests. If
you reach for `unwrap`, the error path wasn't specified — stop and you reach for `unwrap`, the error path wasn't specified, so stop and
decide what should actually happen. For poisoned `RwLock`/`Mutex`, decide what should actually happen. For poisoned `RwLock`/`Mutex`,
use `unwrap_or_else(|e| e.into_inner())` so a panic in one operation use `unwrap_or_else(|e| e.into_inner())` so a panic in one operation
does not cascade to other operations. does not cascade to other operations.
3. **`tokio` is the async runtime** — all I/O is async. `fast-socks5` is 3. **`tokio` is the async runtime**: all I/O is async. `fast-socks5` is
tokio-native; the wrapper's protocol layer, pumps, and client session tokio-native; the wrapper's protocol layer, pumps, and client session
types are all async. Use `tokio::sync` primitives (`oneshot`, `mpsc`) types are all async. Use `tokio::sync` primitives (`oneshot`, `mpsc`)
for lifecycle correlation. Any non-wasm backend module (local TCP for lifecycle correlation. Any non-wasm backend module (local TCP
listener for a "real" `socks5://` endpoint) lives behind a feature listener for a "real" `socks5://` endpoint) lives behind a feature
flag like alktty/alktunnels' `local`. flag like alktty/alktunnels' `local`.
4. **WASM target is load-bearing (by default)** — the default crate 4. **WASM target is load-bearing (by default)**, meaning the default crate
(protocol-only) should compile to `wasm32-unknown-unknown`, following (protocol-only) should compile to `wasm32-unknown-unknown`, following
the alktty/alktunnels precedent. Use the wasm-clean tokio subset the alktty/alktunnels precedent. Use the wasm-clean tokio subset
(`rt`, `sync`, `io-util`, `macros`, `time`) — **do NOT use `features (`rt`, `sync`, `io-util`, `macros`, `time`); **do NOT use `features
= ["full"]`** — and keep socket/platform I/O feature-gated. Note: = ["full"]`**; and keep socket/platform I/O feature-gated. Note:
`fast-socks5` uses `tokio::net` and `socket2` unconditionally as `fast-socks5` uses `tokio::net` and `socket2` unconditionally as
published; the posture is the **targeted fork** (OQ-SK-04, published; the posture is the **targeted fork** (OQ-SK-04,
resolved 2026-09-20 — convention 17; extraction pinned 2026-09-21, resolved 2026-09-20, convention 17; extraction pinned 2026-09-21,
ADR-011 §Amendment; fork 2026-09-22, ADR-013): the `T`-generic ADR-011 §Amendment; fork 2026-09-22, ADR-013): the `T`-generic
subset was extracted in-tree — the kernel-net handlers are not subset was extracted in-tree (the kernel-net handlers are not
copied — so the RFC 1928 core (`src/rfc1928/`) is wasm-clean by copied), so the RFC 1928 core (`src/rfc1928/`) is wasm-clean by
construction with no `net`-shaped feature; socket I/O rides construction with no `net`-shaped feature; socket I/O rides
`local` alone. Run the wasm check whenever a non-backend `local` alone. Run the wasm check whenever a non-backend
module changes; it is a hard gate (no dependency-graph failure is module changes; it is a hard gate (no dependency-graph failure is
expected — the core contains no `tokio::net`). expected, since the core contains no `tokio::net`).
5. **Wire format is stable** — the SOCKS5 protocol itself is RFC 1928 5. **Wire format is stable**: the SOCKS5 protocol itself is RFC 1928
(fixed); this crate's wire surface is the ALPN + the channel open-op (fixed); this crate's wire surface is the ALPN + the channel open-op
params that carry it. Those params are a one-way door once consumers params that carry it. Those params are a one-way door once consumers
exist. Follow the alktunnels precedent: params are a self-contained exist. Follow the alktunnels precedent: params are a self-contained
JSON object identifying the produced resource (alkcall ADR-039 — JSON object identifying the produced resource (alkcall ADR-039,
`params` is ALPN-specific, interpreted by the open handler). Any whose `params` is ALPN-specific, interpreted by the open handler). Any
params-format ADR must be written before the first consumer exists; params-format ADR must be written before the first consumer exists;
after that, changes are additive-only. after that, changes are additive-only.
6. **Producer/consumer, not server/client** — both sides of a channels 6. **Producer/consumer, not server/client**, because both sides of a
connection can initiate. A producer exposes the SOCKS5 service channels connection can initiate. A producer exposes the SOCKS5 service
(registers openable channels via `ChannelCore::register_openable`); a (registers openable channels via `ChannelCore::register_openable`); a
consumer opens SOCKS5 channels and speaks RFC 1928 inside them. Both consumer opens SOCKS5 channels and speaks RFC 1928 inside them. Both
sides can be both simultaneously — connection direction (who opened sides can be both simultaneously; connection direction (who opened
it) is independent of service direction (who dials targets, who it) is independent of service direction (who dials targets, who
serves). Avoid "server" and "client" framing in docs and API names; serves). Avoid "server" and "client" framing in docs and API names;
use "producer" and "consumer," or "accept side" / "connect side" for use "producer" and "consumer," or "accept side" / "connect side" for
@@ -121,32 +121,32 @@ implementation agents.
client/server roles are RFC vocabulary and keep their RFC names client/server roles are RFC vocabulary and keep their RFC names
inside the protocol layer.) See alkcall ADR-022, ADR-037. inside the protocol layer.) See alkcall ADR-022, ADR-037.
7. **Substrate-agnostic by construction** — the protocol layer must not 7. **Substrate-agnostic by construction**: the protocol layer must not
know whether the far side of a SOCKS5 connection is a kernel TCP know whether the far side of a SOCKS5 connection is a kernel TCP
socket, a channels `BiStream`, a unix socket, or an in-process pipe. socket, a channels `BiStream`, a unix socket, or an in-process pipe.
`fast-socks5`'s explicit server API is generic over `fast-socks5`'s explicit server API is generic over
`T: AsyncRead + AsyncWrite + Unpin` (`Socks5ServerProtocol<T, ...>`, `T: AsyncRead + AsyncWrite + Unpin` (`Socks5ServerProtocol<T, ...>`,
`transfer(inbound, outbound)`); the wrapper must preserve that `transfer(inbound, outbound)`); the wrapper must preserve that
genericity — the channels adapter feeds the SOCKS5 state machine a genericity: the channels adapter feeds the SOCKS5 state machine a
`BiStream` (which is `AsyncRead + AsyncWrite`), the local backend `BiStream` (which is `AsyncRead + AsyncWrite`), the local backend
feeds it a `TcpStream`. Substrate-specific types are confined to feeds it a `TcpStream`. Substrate-specific types are confined to
feature-gated backend modules, injected at the assembly layer — the feature-gated backend modules, injected at the assembly layer, the
same inversion-point pattern as alktty's `TtyBackend` and same inversion-point pattern as alktty's `TtyBackend` and
alktunnels' pump halves. alktunnels' pump halves.
8. **Two-pump shutdown-on-completion is a contract** — the SOCKS5 8. **Two-pump shutdown-on-completion is a contract**: the SOCKS5
CONNECT data plane is the canonical two-pump shape (one pump per CONNECT data plane is the canonical two-pump shape (one pump per
direction). Each pump MUST shut down the opposite sink when it direction). Each pump MUST shut down the opposite sink when it
completes; `tokio::try_join!` alone deadlocks. The helper is pinned completes; `tokio::try_join!` alone deadlocks. The helper is pinned
upstream as alkcall `channels::pump_bidi` (ADR-050, review 007 R-03) upstream as alkcall `channels::pump_bidi` (ADR-050, review 007 R-03), so
— use it; do not hand-roll the two-pump shape. The channels layer use it; do not hand-roll the two-pump shape. The channels layer
drops all per-channel senders on transport EOF — rely on that for drops all per-channel senders on transport EOF, so rely on that for
teardown, and emit EOF sentinels (zero-length chunks) on clean sink teardown, and emit EOF sentinels (zero-length chunks) on clean sink
shutdown. Await `pump_bidi` inline inside the `OpenHandler`'s task — shutdown. Await `pump_bidi` inline inside the `OpenHandler`'s task, because
the returned `JoinHandle` must track the data-plane lifetime (R-02; the returned `JoinHandle` must track the data-plane lifetime (R-02;
early return = teardown-at-birth). early return = teardown-at-birth).
9. **No forced local binding** — the defining requirement of this crate. 9. **No forced local binding**: the defining requirement of this crate.
The SOCKS5 server must run without binding any port: the RFC 1928 The SOCKS5 server must run without binding any port: the RFC 1928
conversation is carried inside a channels data channel, and target conversation is carried inside a channels data channel, and target
dials happen on the producing side (or hop further through dials happen on the producing side (or hop further through
@@ -154,32 +154,32 @@ implementation agents.
assembly-layer shapes on either side: the producer may serve from a assembly-layer shapes on either side: the producer may serve from a
real listener (a feature-gated local backend), and the consumer may real listener (a feature-gated local backend), and the consumer may
expose the resource on a local port (the ssh `-D` front door that expose the resource on a local port (the ssh `-D` front door that
vanilla clients — curl, browsers, tun2proxy — point at). The vanilla clients (curl, browsers, tun2proxy) point at). The
protocol itself never binds. UDP ASSOCIATE is the hard case: RFC protocol itself never binds. UDP ASSOCIATE is the hard case: RFC
1928 replies with a UDP relay address and the client sends 1928 replies with a UDP relay address and the client sends
datagrams to it, which seems to demand a bind — how the relay datagrams to it, which seems to demand a bind; how the relay
address is virtualized (or the associate shape adapted) is a Phase 0 address is virtualized (or the associate shape adapted) is a Phase 0
question (OQ-SK-03). Binding decisions belong to the caller question (OQ-SK-03). Binding decisions belong to the caller
(assembly layer), never the protocol crate. (assembly layer), never the protocol crate.
10. **Backpressure and limits are inherited, not redefined** — bounded 10. **Backpressure and limits are inherited, not redefined**: bounded
per-channel buffers, the 256-channel default caps (per-connection per-channel buffers, the 256-channel default caps (per-connection
memory bound, ADR-040; per-identity DoS defense, ADR-041 — policy memory bound, ADR-040; per-identity DoS defense, ADR-041, i.e. policy
knobs, configurable without a wire change; the `u32` wire space knobs, configurable without a wire change; the `u32` wire space
itself is ~4 billion), monotonic itself is ~4 billion), monotonic
channel IDs, and the zero-length-sentinel EOF convention are channel IDs, and the zero-length-sentinel EOF convention are
alkcall channels invariants (see alkcall ADR-040/041/034). The alkcall channels invariants (see alkcall ADR-040/041/034). The
crate consumes them via `ChannelCore`/`ChannelClient`; do not build crate consumes them via `ChannelCore`/`ChannelClient`; do not build
a second demux/mux or re-derive limits. SOCKS5 CONNECT is a single a second demux/mux or re-derive limits. SOCKS5 CONNECT is a single
stream per open (no sub-demux inside the channel) — one channel per stream per open (no sub-demux inside the channel); one channel per
SOCKS5 session, no protocol-level flow key. SOCKS5 session, no protocol-level flow key.
11. **Vendored core types come from alkcall** — `Connection`, 11. **Vendored core types come from alkcall**: `Connection`,
`ProtocolHandler`, `BiStream`, `BidiStreamSource`, `AuthContext`, `ProtocolHandler`, `BiStream`, `BidiStreamSource`, `AuthContext`,
`Identity`, `IdentityProvider`, `AccessControl`, `Identity`, `IdentityProvider`, `AccessControl`,
`OwnershipProvider`, `HandlerError`, `StreamError` come from `OwnershipProvider`, `HandlerError`, `StreamError` come from
`alkcall::core`. Do not vendor copies into this crate. alkcall is `alkcall::core`. Do not vendor copies into this crate. alkcall is
v0.10.x — breaking changes are expected at this major-zero stage; v0.10.x, where breaking changes are expected at this major-zero stage;
this is an early consumer, so we find and fix issues upstream this is an early consumer, so we find and fix issues upstream
rather than working around them. Pin `alkcall = "0.10.1"` and bump rather than working around them. Pin `alkcall = "0.10.1"` and bump
deliberately. The establishment surface (alkcall ADR-049 + deliberately. The establishment surface (alkcall ADR-049 +
@@ -187,21 +187,21 @@ implementation agents.
use `register_openable_with_establisher` so a refused/auth-failed use `register_openable_with_establisher` so a refused/auth-failed
session is a typed `channel:open_failed` call error, never a session is a typed `channel:open_failed` call error, never a
phantom channel; the establisher returns the negotiated handle via phantom channel; the establisher returns the negotiated handle via
`Establishment::new(plan)` (typed-opaque `ChannelPlan` — payloads `Establishment::new(plan)` (typed-opaque `ChannelPlan`, whose payloads
must be `Send + Sync`; 0.8.0 adds the optional reply projection — must be `Send + Sync`; 0.8.0 adds the optional reply projection,
`with_reply_field(s)`, ADR-049 amendment 3). The 0.7.0 identity `with_reply_field(s)` from ADR-049 amendment 3). The 0.7.0 identity
surface (ledger CF-005/CF-006) is load-bearing for auth mapping: surface (ledger CF-005/CF-006) is load-bearing for auth mapping:
the per-call opener identity arrives on the open-op hooks — map it the per-call opener identity arrives on the open-op hooks; map it
to SOCKS5 credentials/ACL there, not via any in-band SOCKS auth to SOCKS5 credentials/ACL there, not via any in-band SOCKS auth
unless clients need RFC-facing username/password. unless clients need RFC-facing username/password.
12. **Access control** — the producer's openable channels get 12. **Access control**: the producer's openable channels get
authorization for free via `ChannelCore::register_openable`, which authorization for free via `ChannelCore::register_openable`, which
wires `AccessControl` into the operation spec (the registry runs wires `AccessControl` into the operation spec (the registry runs
the ACL before the open handler). Scope-gate SOCKS5 opens (e.g. the ACL before the open handler). Scope-gate SOCKS5 opens (e.g.
`SOCKS5_OPEN_SCOPE`, shape following alktty's `TTY_OPEN_SCOPE`) and `SOCKS5_OPEN_SCOPE`, shape following alktty's `TTY_OPEN_SCOPE`) and
optionally consult `OwnershipProvider` for resource ownership. optionally consult `OwnershipProvider` for resource ownership.
SOCKS5 is arbitrary-egress by nature — treat the open gate and the SOCKS5 is arbitrary-egress by nature; treat the open gate and the
target policy as the security boundary; per-target allowlists (if target policy as the security boundary; per-target allowlists (if
any) are a producer-side policy question for Phase 1 (OQ-SK-05). any) are a producer-side policy question for Phase 1 (OQ-SK-05).
See alknet ADR-024 (registry layering, alkcall ADR-019), alknet See alknet ADR-024 (registry layering, alkcall ADR-019), alknet
@@ -210,45 +210,45 @@ implementation agents.
producing side; whatever ACL governs the socks5 resource governs producing side; whatever ACL governs the socks5 resource governs
everything reachable through it). everything reachable through it).
13. **Feature flags** — substrate backends are feature-gated if the 13. **Feature flags**: substrate backends are feature-gated if the
need arises. The base crate should compile lean (no socket/platform need arises. The base crate should compile lean (no socket/platform
deps unless the feature is on). Verify both `cargo test` (default) deps unless the feature is on). Verify both `cargo test` (default)
and `cargo test --all-features` pass if features are added. and `cargo test --all-features` pass if features are added.
14. **Naming** — Rust standard: `snake_case` for functions/variables/ 14. **Naming** follows the Rust standard: `snake_case` for functions/variables/
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for
constants. constants.
15. **Module structure** — one module per file under `src/`, re-exported 15. **Module structure**: one module per file under `src/`, re-exported
from `src/lib.rs`. Public API surface is `lib.rs` re-exports. The from `src/lib.rs`. Public API surface is `lib.rs` re-exports. The
expected shape (pending Phase 1 pinning): the SOCKS5 protocol expected shape (pending Phase 1 pinning): the SOCKS5 protocol
wrapper (server state machine + client session), the channels wrapper (server state machine + client session), the channels
adapter (producer half — open handler for this crate's ALPN), the adapter (the producer half, the open handler for this crate's ALPN), the
consumer half (a typed client that opens SOCKS5 channels and consumer half (a typed client that opens SOCKS5 channels and
speaks RFC 1928 through them), and feature-gated backend modules speaks RFC 1928 through them), and feature-gated backend modules
(the optional local-bind listener). Backend modules are (the optional local-bind listener). Backend modules are
feature-gated and never imported from the protocol/adapter/client feature-gated and never imported from the protocol/adapter/client
modules. modules.
16. **ALPN naming** — this crate owns a `alk/`-prefixed ALPN (provisional 16. **ALPN naming**: this crate owns a `alk/`-prefixed ALPN (provisional
`alk/socks5`; final naming per alkcall ADR-004/ADR-006 convention). `alk/socks5`; final naming per alkcall ADR-004/ADR-006 convention).
One ALPN per protocol. The ALPN string is wire-stable once One ALPN per protocol. The ALPN string is wire-stable once
published — decide via ADR before the first consumer. published; decide via ADR before the first consumer.
17. **Upstream: alkcall is ours to shape; fast-socks5 is forked 17. **Upstream: alkcall is ours to shape; fast-socks5 is forked
in-tree.** in-tree.**
alkcall is ours (the actively co-developed substrate): file asks alkcall is ours (the actively co-developed substrate): file asks
early and land them there rather than working around them locally — early and land them there rather than working around them locally;
the alktunnels precedent (the E-01/E-02 sweep, filed from its the alktunnels precedent (the E-01/E-02 sweep, filed from its
Phase 0 and landed in alkcall 0.5.0 within a day; the graduation Phase 0 and landed in alkcall 0.5.0 within a day; the graduation
sweep landed in alkcall 0.8.0 / alktunnels 0.2.0). `fast-socks5` sweep landed in alkcall 0.8.0 / alktunnels 0.2.0). `fast-socks5`
is NOT ours and never will be — it is third-party upstream is NOT ours and never will be; it is third-party upstream
(`/workspace/fast-socks5` is a read-only reference checkout, (`/workspace/fast-socks5` is a read-only reference checkout,
v1.0.0, MIT), but the `T`-generic subset we depend on is now v1.0.0, MIT), but the `T`-generic subset we depend on is now
**ours, as a targeted fork** (OQ-SK-04 resolved 2026-09-20 as the **ours, as a targeted fork** (OQ-SK-04 resolved 2026-09-20 as the
inline-vendor extraction; extraction pinned 2026-09-21, ADR-011 inline-vendor extraction; extraction pinned 2026-09-21, ADR-011
§Amendment; fork posture pinned 2026-09-22, ADR-013): the subset §Amendment; fork posture pinned 2026-09-22, ADR-013): the subset
lives in `src/rfc1928/` as fully owned code — project conventions lives in `src/rfc1928/` as fully owned code, and project conventions
apply to it unconditionally (no vendor carve-outs, no apply to it unconditionally (no vendor carve-outs, no
lint-allow posture, no verbatim preservation). The extraction lint-allow posture, no verbatim preservation). The extraction
cut (ADR-011's table) is the hand-port map: if a future need cut (ADR-011's table) is the hand-port map: if a future need
@@ -256,12 +256,30 @@ implementation agents.
vanilla-server example), port the piece from the reference vanilla-server example), port the piece from the reference
checkout, adapt it to the conventions, and let the differential checkout, adapt it to the conventions, and let the differential
suite guard it. A future upstream fix that matters is a suite guard it. A future upstream fix that matters is a
hand-port too — nothing is absorbed silently, and nothing waits hand-port too; nothing is absorbed silently, and nothing waits
on upstream. Keep `/workspace/fast-socks5` for differential on upstream. Keep `/workspace/fast-socks5` for differential
testing of RFC edge cases. Provenance is permanent: the module testing of RFC edge cases. Provenance is permanent: the module
docs and the bundled license notice (`src/rfc1928/LICENSE`) docs and the bundled license notice (`src/rfc1928/LICENSE`)
always name the origin (v1.0.0, the extraction range, the fork always name the origin (v1.0.0, the extraction range, the fork
point) — extend, never replace, the notice. point): extend, never replace, the notice.
18. **No em dashes in prose.** Do not use the em dash (U+2014) in source
doc comments, markdown docs (including all of `docs/` and `tasks/`),
agent prompts under `.opencode/agents/`, or commit messages.
Restructure instead: split the sentence, use parentheses, commas, a
colon, or a semicolon, whichever reads naturally, and vary the
choice rather than converting every instance the same way. A spaced
hyphen (` - `) is not an acceptable substitute; it reads as a
different but equally recognizable writing tell. Inside fenced code
blocks the rule applies to prose-y content only: line comments,
ASCII-diagram label separators, and template placeholders are
rewritten too, while wire data, JSON/BAST examples, and tool
transcripts survive verbatim. The same rule covers en dashes used as
clause separators, though en dashes inside numeric ranges keep their
normal meaning. Hyphens in compound words (`wasm-clean`,
`fast-socks5`, `alk/socks5`) are untouched. The one exemption is
`docs/reviews/`: reviews are archived historical records, so their
prose is preserved verbatim and is excluded from the em-dash greps.
## Verification Commands ## Verification Commands
@@ -282,8 +300,8 @@ cargo publish --dry-run --allow-dirty # before a release
### Release steps (after the version bump + verification) ### Release steps (after the version bump + verification)
1. Commit the release (bump + CHANGELOG/README/AGENTS pins). 1. Commit the release (bump + CHANGELOG/README/AGENTS pins).
2. Tag it: `git tag -a vX.Y.Z -m "release: vX.Y.Z — <the tag>"` 2. Tag it: `git tag -a vX.Y.Z -m "release: vX.Y.Z (<the tag>)"`
— **annotated**, always (v0.1.0/v0.2.0 are lightweight; annotated **annotated**, always (v0.1.0/v0.2.0 are lightweight; annotated
since the v0.3.0 release, 2026-10-19). since the v0.3.0 release, 2026-10-19).
3. `git push origin main && git push origin vX.Y.Z`. 3. `git push origin main && git push origin vX.Y.Z`.
4. `cargo publish` (no `--allow-dirty` once the tree is clean). 4. `cargo publish` (no `--allow-dirty` once the tree is clean).
@@ -293,51 +311,51 @@ The corpus replay is the fuzz gate (the alkcall §7.9 posture): it
replays every committed seed through the same invariant functions the replays every committed seed through the same invariant functions the
fuzz targets run, on stable, without nightly. Campaigns (nightly, fuzz targets run, on stable, without nightly. Campaigns (nightly,
cargo-fuzz) run manually via `fuzz/run-detached.sh` when cargo-fuzz) run manually via `fuzz/run-detached.sh` when
`src/wire.rs`, `src/rfc1928/`, or `src/params.rs` change — never as a `src/wire.rs`, `src/rfc1928/`, or `src/params.rs` change, never as a
foreground child of an agent session (docs/research/fuzzing.md §5). foreground child of an agent session (docs/research/fuzzing.md §5).
The wasm check is the structural guard for the "default crate is The wasm check is the structural guard for the "default crate is
wasm-clean" invariant (same as alktty/alktunnels) — run it whenever a wasm-clean" invariant (same as alktty/alktunnels); run it whenever a
non-backend module changes. It is a hard gate: the RFC 1928 core is non-backend module changes. It is a hard gate: the RFC 1928 core is
an extraction with no kernel-net code (ADR-011 §Amendment), so no an extraction with no kernel-net code (ADR-011 §Amendment), so no
dependency-graph failure is expected. dependency-graph failure is expected.
## Architecture Context ## Architecture Context
- `docs/research/phase-0.md` — the Phase 0 (Exploration) document and - `docs/research/phase-0.md` holds the Phase 0 (Exploration) document and
the current state of this repo: vision, prior art, open questions the current state of this repo: vision, prior art, open questions
(OQ-SK-01..NN), and POC candidates. Read it before non-trivial work. (OQ-SK-01..NN), and POC candidates. Read it before non-trivial work.
The SDD process lives in `docs/sdd_process.md` (Phase 0 in progress; The SDD process lives in `docs/sdd_process.md` (Phase 0 in progress;
`docs/architecture/` does not exist yet). `docs/architecture/` does not exist yet).
- The prior art for this crate: - The prior art for this crate:
- **alktunnels** — `/workspace/@alkdev/alktunnels` (v0.1, the closest - **alktunnels** (`/workspace/@alkdev/alktunnels`, v0.1, the closest
sibling): the producer/consumer tunnel crate on alkcall channels. sibling): the producer/consumer tunnel crate on alkcall channels.
Its Phase 0 findings (`docs/research/phase-0-findings.md`) settled Its Phase 0 findings (`docs/research/phase-0-findings.md`) settled
the hub-owns-the-connection model, the two-pump shape, the the hub-owns-the-connection model, the two-pump shape, the
establishment story (alkcall ADR-049), and the `-D`-composes-at-the- establishment story (alkcall ADR-049), and the `-D`-composes-at-the-
assembly-layer conclusion — this crate is that conclusion's assembly-layer conclusion; this crate is that conclusion's
realization. Its Phase 1 ADRs (params shape, ALPN strategy, realization. Its Phase 1 ADRs (params shape, ALPN strategy,
access-control posture) are the template for this crate's Phase 1. access-control posture) are the template for this crate's Phase 1.
- **alktty** — `/workspace/@alkdev/alktty` (v0.4): the first - **alktty** (`/workspace/@alkdev/alktty`, v0.4): the first
producer/consumer protocol crate on alkcall channels; the backend producer/consumer protocol crate on alkcall channels; the backend
inversion point, wasm-clean default crate, and feature-gated local inversion point, wasm-clean default crate, and feature-gated local
backend precedents. backend precedents.
- **alkcall** — `/workspace/@alkdev/alkcall` (v0.9.x, crates.io). The - **alkcall** (`/workspace/@alkdev/alkcall`, v0.9.x, crates.io). The
substrate: call protocol + channels multiplexing. This crate substrate: call protocol + channels multiplexing. This crate
consumes `alkcall::core` types and the channels consumes `alkcall::core` types and the channels
`ChannelCore`/`ChannelClient`/`register_openable_with_establisher` `ChannelCore`/`ChannelClient`/`register_openable_with_establisher`
surface, same as alktty/alktunnels. 0.8.0 adds the establisher surface, same as alktty/alktunnels. 0.8.0 adds the establisher
reply projection (`with_reply_field`, ADR-049 amendment 3), reply projection (`with_reply_field`, ADR-049 amendment 3),
flavor-form open-op ids in discovery (ADR-047 amendment 3), and flavor-form open-op ids in discovery (ADR-047 amendment 3), and
the in-tree `ChannelRelay`/`HubLegTemplate` (ADR-051) — the hub the in-tree `ChannelRelay`/`HubLegTemplate` (ADR-051), the hub
re-produce machinery the SOCKS5 hub story composes on. re-produce machinery the SOCKS5 hub story composes on.
- **alktunnels** — v0.2.0 (ADRs 007/008): the direct op - **alktunnels** (v0.2.0, ADRs 007/008): the direct op
(`channels/tunnel/direct` — dynamic-target egress; OQ-SK-01 (`channels/tunnel/direct`, dynamic-target egress; OQ-SK-01
Option A's vehicle), the bind-first establisher + forwarded op Option A's vehicle), the bind-first establisher + forwarded op
(the composed BIND substrate; OQ-SK-08 variant 2), and (the composed BIND substrate; OQ-SK-08 variant 2), and
`TunnelListener`. `TunnelListener`.
- **fast-socks5** — `/workspace/fast-socks5` (v1.0.0, MIT, - **fast-socks5** (`/workspace/fast-socks5`, v1.0.0, MIT,
third-party upstream — NOT ours, never will be). The SOCKS5 third-party upstream, NOT ours and never will be). The SOCKS5
implementation the RFC 1928 core forked: explicit typestate implementation the RFC 1928 core forked: explicit typestate
server API (`Socks5ServerProtocol<T, states::*>`), generic over server API (`Socks5ServerProtocol<T, states::*>`), generic over
`T: AsyncRead + AsyncWrite + Unpin`; `run_tcp_proxy` / `T: AsyncRead + AsyncWrite + Unpin`; `run_tcp_proxy` /
@@ -346,48 +364,48 @@ dependency-graph failure is expected.
and `Socks5Datagram` (UDP associate). The posture is the and `Socks5Datagram` (UDP associate). The posture is the
**targeted fork** (OQ-SK-04 resolved 2026-09-20 as the **targeted fork** (OQ-SK-04 resolved 2026-09-20 as the
inline-vendor extraction; extraction pinned 2026-09-21, ADR-011 inline-vendor extraction; extraction pinned 2026-09-21, ADR-011
§Amendment; fork 2026-09-22, ADR-013 — convention 17): the §Amendment; fork 2026-09-22, ADR-013, convention 17): the
`T`-generic subset lives in `src/rfc1928/` as owned code, no `T`-generic subset lives in `src/rfc1928/` as owned code, no
kernel-net code and no `net` feature; BIND and the `new_reply` kernel-net code and no `net` feature; BIND and the `new_reply`
publication are the two carried changes; the reference checkout publication are the two carried changes; the reference checkout
stays for differential testing of RFC edge cases. stays for differential testing of RFC edge cases.
- **alknet's SOCKS5 client** — - **alknet's SOCKS5 client** at
`/workspace/@alkdev/alknet/crates/alknet-client/src/socks5.rs` `/workspace/@alkdev/alknet/crates/alknet-client/src/socks5.rs`
(ADR-090): the client-side example need — `Socks5UdpSocket` (ADR-090), the client-side example: `Socks5UdpSocket`
implements `quinn::AsyncUdpSocket` so QUIC rides SOCKS5 UDP implements `quinn::AsyncUdpSocket` so QUIC rides SOCKS5 UDP
ASSOCIATE. Validated by the quinn-proxy POC ASSOCIATE. Validated by the quinn-proxy POC
(`/workspace/@alkdev/alknet/docs/research/quinn-quic-proxy/ (`/workspace/@alkdev/alknet/docs/research/quinn-quic-proxy/
findings.md`). The open question for this crate's client story: the findings.md`). The open question for this crate's client story: the
same trait impl against **noq** (iroh's quinn fork, same trait impl against **noq** (iroh's quinn fork,
`/workspace/noq` — `noq::AsyncUdpSocket`, `Endpoint:: `/workspace/noq`, which has `noq::AsyncUdpSocket`, `Endpoint::
new_with_abstract_socket` exist but with a different surface shape; new_with_abstract_socket` exist but with a different surface shape;
see OQ-SK-06). see OQ-SK-06).
- Key upstream ADRs that inform this crate's design (alknet numbers; - Key upstream ADRs that inform this crate's design (alknet numbers;
alkcall's ports live in `/workspace/@alkdev/alkcall/docs/architecture/ alkcall's ports live in `/workspace/@alkdev/alkcall/docs/architecture/
decisions/`): decisions/`):
- alknet ADR-093 / alkcall ADR-035 — channels pure channel - alknet ADR-093 / alkcall ADR-035: channels pure channel
multiplexing (8-byte header, no `stream_type`); the SOCKS5 session multiplexing (8-byte header, no `stream_type`); the SOCKS5 session
rides inside the channel's `BiStream` rides inside the channel's `BiStream`
- alknet ADR-078 / alkcall ADR-050 — two-pump shutdown-on-completion; - alknet ADR-078 / alkcall ADR-050: two-pump shutdown-on-completion;
the helper is pinned upstream as `alkcall::channels::pump_bidi` — the helper is pinned upstream as `alkcall::channels::pump_bidi`
use it, do not hand-roll (use it, do not hand-roll)
- alkcall ADR-049 (amendment 2) — the establishment phase; the - alkcall ADR-049 (amendment 2): the establishment phase; the
establisher returns the negotiated handle via `Establishment::new` establisher returns the negotiated handle via `Establishment::new`
(typed-opaque `ChannelPlan`, `Send + Sync` payload constraint) (typed-opaque `ChannelPlan`, `Send + Sync` payload constraint)
- alknet ADR-074 / alkcall ADR-038 — `ChannelConnection` as a - alknet ADR-074 / alkcall ADR-038: `ChannelConnection` as a
`BidiStreamSource`; every handler receives a `Connection` `BidiStreamSource`; every handler receives a `Connection`
- alknet ADR-075 / alkcall ADR-039 — `ChannelsAdapter` / - alknet ADR-075 / alkcall ADR-039: `ChannelsAdapter` /
`ChannelManager` (substrate-agnostic demux; `params` is `ChannelManager` (substrate-agnostic demux; `params` is
ALPN-specific — for this crate, the SOCKS5 service identity/policy) ALPN-specific, i.e. for this crate the SOCKS5 service identity/policy)
- alknet ADR-071 / alkcall ADR-034 — channels wire format (one-way - alknet ADR-071 / alkcall ADR-034: channels wire format (one-way
door); alkcall ADR-036 — channel 0 is pre-negotiated `alk/call`; door); alkcall ADR-036: channel 0 is pre-negotiated `alk/call`;
alkcall ADR-037 — channel lifecycle operations (`channel/open` on alkcall ADR-037: channel lifecycle operations (`channel/open` on
channel 0) channel 0)
- alkcall ADR-042 — hub relay; alktunnels' hub-proxy refinement — - alkcall ADR-042: hub relay; alktunnels' hub-proxy refinement, in which
the hub terminates and re-produces per-hop ACL (a SOCKS5 resource the hub terminates and re-produces per-hop ACL (a SOCKS5 resource
is the same shape, "further downstream" — alktunnels is the same shape, "further downstream", per alktunnels
phase-0-findings §Prior art) phase-0-findings §Prior art)
- alkcall ledger CF-005/CF-006 (0.7.0) — the connect-side serving - alkcall ledger CF-005/CF-006 (0.7.0): the connect-side serving
identity seam: caller identity precedence (token > identity seam: caller identity precedence (token >
`ServingConfig.identity` > transport), per-call opener identity on `ServingConfig.identity` > transport), per-call opener identity on
the open-op hooks the open-op hooks
@@ -398,5 +416,5 @@ dependency-graph failure is expected.
to composition). `alknet-client/src/socks5.rs` and the quinn-proxy to composition). `alknet-client/src/socks5.rs` and the quinn-proxy
POC findings are the direct ancestors of this crate's client work. POC findings are the direct ancestors of this crate's client work.
- If a TODO references a design direction that a later ADR has decided - If a TODO references a design direction that a later ADR has decided
against, the TODO is stale — remove it and align with the ADR. Do not against, the TODO is stale; remove it and align with the ADR. Do not
implement the rejected design. implement the rejected design.
+66 -36
View File
@@ -6,6 +6,35 @@ this crate adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [0.3.2] - 2026-11-03
The dependency-patch release: the alktunnels sibling bumped 0.4.1 →
0.5.0 (the correlation-gate release). No alkcall bump (0.10.1 rides
on), no alksocks API change, no wire change, and no change on this
crate's paths: the composed accept stays on the base
`register_tunnel_forwarded_openable` (kept byte-for-byte upstream), so
the new serving-side establishment phase is an adoption question, not
a bump requirement. Patch rather than minor: the dependency bump is
semver-compatible (additive API only), matching the 0.3.1 precedent.
### Changed
- **alktunnels 0.4.1 → 0.5.0** (crates.io). The correlation-gate
release (routed from alkssh's downstream experience): the forwarded
op's serving half gains an establishment phase:
`register_tunnel_forwarded_openable_with_establisher` + `ForwardedGate`
+ `ForwardedPlan`, so a stale/missing `listen` token fails the open
as a typed `channel:open_failed` with
`details.reason == "unknown_resource"` BEFORE any channel is born
(extending alkcall ADR-049's posture to the forwarded op's serving
side). Additive public API only; the wire delta is empty and the
base `register_tunnel_forwarded_openable` stays byte-for-byte.
Adoption in this crate: available to the composed accept's
`register_forwarded_serving` (the correlation registry is assembly
side, so a `ForwardedGate` over the token map is the adoption
shape), but not taken in this bump; the base registration remains
the compiled path and the residual-race fallback posture.
## [0.3.1] - 2026-10-22 ## [0.3.1] - 2026-10-22
The dependency-patch release: the alkcall substrate bumped 0.10.0 → The dependency-patch release: the alkcall substrate bumped 0.10.0 →
@@ -29,7 +58,7 @@ dependency bumps are semver-compatible (0.10.x → 0.10.1, 0.4.x →
park-drain are one atomic section; no await runs inside it. Relevant park-drain are one atomic section; no await runs inside it. Relevant
in principle to this crate's reverse-path adopt flow (the in principle to this crate's reverse-path adopt flow (the
bind-first establisher surfaces channels through the same adopt bind-first establisher surfaces channels through the same adopt
path), though no change in this crate's own surfaces was needed — path), though no change in this crate's own surfaces was needed:
the fix surface is private (`ChannelState`), the behavior only the fix surface is private (`ChannelState`), the behavior only
strengthens. No API, wire-format, or documented-cap behavior change strengthens. No API, wire-format, or documented-cap behavior change
upstream. upstream.
@@ -37,9 +66,9 @@ dependency bumps are semver-compatible (0.10.x → 0.10.1, 0.4.x →
0.10.1). No alktunnels API/wire change; the composed providers see 0.10.1). No alktunnels API/wire change; the composed providers see
none of it. none of it.
- **fuzz/shared harness pin** (the only file-level contacts are the - **fuzz/shared harness pin** (the only file-level contacts are the
`Cargo.toml`s/`Cargo.lock` — plus the doc pins): the harness declares `Cargo.toml`s/`Cargo.lock`, plus the doc pins): the harness declares
its own `alkcall` pin (it constructs `ChannelOpenError` values for its own `alkcall` pin (it constructs `ChannelOpenError` values for
the invariant functions), so the two pins must stay in lockstep — the invariant functions), so the two pins must stay in lockstep:
bumped 0.10.0 → 0.10.1 with the substrate (a drifting pin produces a bumped 0.10.0 → 0.10.1 with the substrate (a drifting pin produces a
duplicate-type compile failure, not silent drift). duplicate-type compile failure, not silent drift).
@@ -61,10 +90,10 @@ semver-visible to downstream consumers (matching the alktunnels
- **alkcall 0.9.0 → 0.10.0** (crates.io). The gateway - **alkcall 0.9.0 → 0.10.0** (crates.io). The gateway
ownership-seam release (review 012): `GatewayDispatch` gains ownership-seam release (review 012): `GatewayDispatch` gains
`with_ownership_provider` / `with_static_ownership_fallback`, and `with_ownership_provider` / `with_static_ownership_fallback`, and
the previously-silent static fallback becomes loud — an unwired the previously-silent static fallback becomes loud: an unwired
spine refuses (an error envelope, `FORBIDDEN`, pre-dispatch) an op spine refuses (an error envelope, `FORBIDDEN`, pre-dispatch) an op
whose spec carries `access_control.resource_type`. None of it is on whose spec carries `access_control.resource_type`. None of it is on
this crate's paths: alksocks does not use the gateway module — this crate's paths: alksocks does not use the gateway module;
SOCKS5 opens serve via `ChannelCore::register_openable`/ SOCKS5 opens serve via `ChannelCore::register_openable`/
`register_openable_with_establisher` registry dispatch on the native `register_openable_with_establisher` registry dispatch on the native
wire, which 0.10.0 leaves untouched. All of this crate's alkcall wire, which 0.10.0 leaves untouched. All of this crate's alkcall
@@ -79,7 +108,7 @@ semver-visible to downstream consumers (matching the alktunnels
- **fuzz/shared harness pin** (the only bump contact): the harness - **fuzz/shared harness pin** (the only bump contact): the harness
declares its own `alkcall` pin (it constructs `ChannelOpenError` declares its own `alkcall` pin (it constructs `ChannelOpenError`
values for the invariant functions), so the two pins must stay in values for the invariant functions), so the two pins must stay in
lockstep — bumped 0.9.0 → 0.10.0 with the substrate (a drifting pin lockstep, bumped 0.9.0 → 0.10.0 with the substrate (a drifting pin
produces a duplicate-type compile failure, not silent drift). produces a duplicate-type compile failure, not silent drift).
## [0.2.0] - 2026-10-14 ## [0.2.0] - 2026-10-14
@@ -106,7 +135,7 @@ consumers (matching the alktunnels 0.2.1 → 0.3.0 ride).
`RelayError::PubTypedOpen` removal, the `RelayError::PubTypedOpen` removal, the
`register_imported`/`register_imported_all` return change, `register_imported`/`register_imported_all` return change,
`Establishment` dropping its `Debug` derive) is untouched by this `Establishment` dropping its `Debug` derive) is untouched by this
crate's API use — `Establishment::default()` is the only crate's API use: `Establishment::default()` is the only
`Establishment` contact, in the session gate. 145 default / 171 `Establishment` contact, in the session gate. 145 default / 171
all-features tests pass; wasm-clean verified. all-features tests pass; wasm-clean verified.
- **alktunnels 0.2.1 → 0.3.0** (the dependency-major ride on alkcall - **alktunnels 0.2.1 → 0.3.0** (the dependency-major ride on alkcall
@@ -116,19 +145,19 @@ consumers (matching the alktunnels 0.2.1 → 0.3.0 ride).
## [0.1.0] - 2026-09-27 ## [0.1.0] - 2026-09-27
The initial release: SOCKS5 (RFC 1928) as a produced resource on The initial release: SOCKS5 (RFC 1928) as a produced resource on
alkcall channels — the `-D` flavor as a first-class protocol crate, alkcall channels, the `-D` flavor as a first-class protocol crate,
over alkcall 0.8.1. The defining posture: the protocol never binds a over alkcall 0.8.1. The defining posture: the protocol never binds a
port; kernel binds exist only as explicit, feature-gated assembly port; kernel binds exist only as explicit, feature-gated assembly
shapes. Composed against by the alktunnels 0.2.1 graduation (the shapes. Composed against by the alktunnels 0.2.1 graduation (the
direct op + the bind-first/forwarded flow power the composed direct op + the bind-first/forwarded flow power the composed
providers). Fuzzing adopted (the `fuzz/` workspace — excluded from providers). Fuzzing adopted (the `fuzz/` workspace, excluded from
the published package) over the crate's two attacker-reachable the published package) over the crate's two attacker-reachable
parsers: the ASSOCIATE datagram codec and the RFC 1928 handshake; parsers: the ASSOCIATE datagram codec and the RFC 1928 handshake;
campaigns clean, no crate bugs. campaigns clean, no crate bugs.
### Added ### Added
- **The RFC 1928 core (`src/rfc1928/`, ADR-013)** — the targeted fork - **The RFC 1928 core (`src/rfc1928/`, ADR-013)**: the targeted fork
of fast-socks5 v1.0.0's `T`-generic subset, extracted in-tree as of fast-socks5 v1.0.0's `T`-generic subset, extracted in-tree as
fully owned code (MIT notice bundled; extraction per ADR-011): the fully owned code (MIT notice bundled; extraction per ADR-011): the
wire consts, codecs, errors, the explicit typestate server API wire consts, codecs, errors, the explicit typestate server API
@@ -136,78 +165,78 @@ campaigns clean, no crate bugs.
`T: AsyncRead + AsyncWrite + Unpin`), and the client `use_stream` `T: AsyncRead + AsyncWrite + Unpin`), and the client `use_stream`
path. Two deliberate changes carried from the fork point: BIND path. Two deliberate changes carried from the fork point: BIND
support in the base (ADR-007) and the `new_reply` publication. No support in the base (ADR-007) and the `new_reply` publication. No
kernel-net code — the default crate is wasm-clean by construction. kernel-net code; the default crate is wasm-clean by construction.
- **The wire codec (`wire`, ADR-003)** — the ASSOCIATE datagram frame - **The wire codec (`wire`, ADR-003)**: the ASSOCIATE datagram frame
(`[len: u16 BE][addr + data]`), framing applied exactly once at (`[len: u16 BE][addr + data]`), framing applied exactly once at
this layer; the `DatagramReader` incremental parser; the this layer; the `DatagramReader` incremental parser; the
`MAX_INNER_LEN` oversize guard. `MAX_INNER_LEN` oversize guard.
- **The wire-stable open-op surface (`params`, ADR-001/006)** — the - **The wire-stable open-op surface (`params`, ADR-001/006)**: the
`alk/socks5` ALPN, the `channels/socks5/sub` op id, the trivial `alk/socks5` ALPN, the `channels/socks5/sub` op id, the trivial
`{}` params (the RFC conversation is the data plane; nothing to `{}` params (the RFC conversation is the data plane; nothing to
negotiate at open time), and the two scope constants negotiate at open time), and the two scope constants
(`socks5:open`, `socks5:bind` — two grants, never implied). The (`socks5:open`, `socks5:bind`; two grants, never implied). The
typed open-error vocabulary rides `error` (the ADR-049 reasons). typed open-error vocabulary rides `error` (the ADR-049 reasons).
- **The three seams (`egress`, ADR-004)** — `DialFn` / - **The three seams (`egress`, ADR-004)**: `DialFn` /
`EgressFactory` / `BindAcceptFactory`: functions, not traits, at `EgressFactory` / `BindAcceptFactory`: functions, not traits, at
the three I/O planes; ownership rides the handle structurally the three I/O planes; ownership rides the handle structurally
(`TargetHandle` / `BindTargetHandle` / `BindAccept`); the egress (`TargetHandle` / `BindTargetHandle` / `BindAccept`); the egress
readiness contract (`next_reply` `Pending` when nothing is ready) readiness contract (`next_reply` `Pending` when nothing is ready)
is load-bearing API. is load-bearing API.
- **The producer half (`producer`)** — the session-gate establisher - **The producer half (`producer`)**: the session-gate establisher
(validates the opener identity; the target is unknown at open (validates the opener identity; the target is unknown at open
time), the command-dispatch pump handler (CONNECT pass-through / time), the command-dispatch pump handler (CONNECT pass-through /
ASSOCIATE datagram stage / BIND phase model — reply#1 → quiet wait ASSOCIATE datagram stage / BIND phase model: reply#1 → quiet wait
→ exactly one accept → reply#2 → pass-through), the command gate → exactly one accept → reply#2 → pass-through), the command gate
(`socks5:bind` at BIND dispatch), the identity witness, and both (`socks5:bind` at BIND dispatch), the identity witness, and both
wiring shapes: the free functions wiring shapes: the free functions
(`register_socks5_openable` / `socks5_session_establisher` / (`register_socks5_openable` / `socks5_session_establisher` /
`make_socks5_pump_handler` / `serve_socks5_over`) and the builder `make_socks5_pump_handler` / `serve_socks5_over`) and the builder
(`Socks5Producer::builder()` — dial/egress/accept/bind_policy). (`Socks5Producer::builder()`: dial/egress/accept/bind_policy).
Data planes ride `alkcall::channels::pump_bidi` (ADR-050). Data planes ride `alkcall::channels::pump_bidi` (ADR-050).
- **The consumer half (`consumer`)** — the typed channels-native - **The consumer half (`consumer`)**: the typed channels-native
sessions, each owning its channel's teardown (`Drop` aborts + sessions, each owning its channel's teardown (`Drop` aborts +
reaps; no `Clone`): [`Socks5ChannelSession`] (CONNECT — raw reaps; no `Clone`): [`Socks5ChannelSession`] (CONNECT: raw
pass-through, the `split`/`into_inner` postures), pass-through, the `split`/`into_inner` postures),
[`AssociateSession`] (the framed datagram stage — [`AssociateSession`] (the framed datagram stage:
`send_datagram`/`recv_datagram`/`split`), [`BindSession`] (the `send_datagram`/`recv_datagram`/`split`), [`BindSession`] (the
reply#1/reply#2 reader + the `BindDataPlane` pass-through), and the reply#1/reply#2 reader + the `BindDataPlane` pass-through), and the
token-carrying surface (`TokenClient` + the `*_with_token` token-carrying surface (`TokenClient` + the `*_with_token`
constructor twins — the cred-bearing front door's re-origination). constructor twins: the cred-bearing front door's re-origination).
- **The `local` feature (ADR-008/009)** — the socket-backed seam - **The `local` feature (ADR-008/009)**: the socket-backed seam
providers (`tcp_dial` / `udp_egress` / `bind_accept` — the explicit providers (`tcp_dial` / `udp_egress` / `bind_accept`: the explicit
bind decisions, resolving via the non-blocking bind decisions, resolving via the non-blocking
`tokio::net::lookup_host` resolver), plus both doors: the `tokio::net::lookup_host` resolver), plus both doors: the
consumer-side front doors (`FrontDoor` — the NoAuth raw pipe + the consumer-side front doors (`FrontDoor`: the NoAuth raw pipe + the
RFC 1929 cred-bearing door with the credential→identity mapping, RFC 1929 cred-bearing door with the credential→identity mapping,
ADR-005; `UdpFrontDoor` — one association per bound relay socket, ADR-005; `UdpFrontDoor`: one association per bound relay socket,
the RFC codec at the door boundary only, the peer-pin posture: the the RFC codec at the door boundary only, the peer-pin posture: the
client's source IP pins on the first parsed datagram, replies client's source IP pins on the first parsed datagram, replies
follow the latest-seen port from that IP, foreign-IP datagrams are follow the latest-seen port from that IP, foreign-IP datagrams are
dropped per RFC 1928's MUST — OQ-SK2-09) and the producer-side dropped per RFC 1928's MUST; OQ-SK2-09) and the producer-side
listener (`ProducerDoor` — vanilla clients dial it, each accepted listener (`ProducerDoor`: vanilla clients dial it, each accepted
connection is served via `serve_socks5_over`; the ADR-014 connection is served via `serve_socks5_over`; the ADR-014
idle-first-byte window + max-sessions cap knobs). idle-first-byte window + max-sessions cap knobs).
- **The `composed` feature (ADR-008/009)** — the alktunnels 0.2.1 - **The `composed` feature (ADR-008/009)**: the alktunnels 0.2.1
composed providers: `direct_egress` (the direct-op egress — a lazy composed providers: `direct_egress` (the direct-op egress: a lazy
destination→tunnel table, `DEFAULT_MAX_TUNNELS` bounded), and destination→tunnel table, `DEFAULT_MAX_TUNNELS` bounded), and
`bindfirst_accept` + `register_forwarded_serving` (the BIND accept `bindfirst_accept` + `register_forwarded_serving` (the BIND accept
whose listener lives on a bind-first listen resource; the whose listener lives on a bind-first listen resource; the
`CorrelationRegistry` correlates the forwarded op's peer metadata `CorrelationRegistry` correlates the forwarded op's peer metadata
into `ForwardedSession`s). into `ForwardedSession`s).
- **The idle-timeout posture (ADR-014)** — every phase - **The idle-timeout posture (ADR-014)**: every phase
teardown-governed, no protocol-layer idle bound; the doors own the teardown-governed, no protocol-layer idle bound; the doors own the
accept-path defense. accept-path defense.
- **The suites** — the differential suite against the upstream - **The suites**: the differential suite against the upstream
reference checkout (`tests/rfc1928-differential.rs`), the reference checkout (`tests/rfc1928-differential.rs`), the
protocol-core integration suite (`tests/core-integration.rs`), and protocol-core integration suite (`tests/core-integration.rs`), and
the assembly-layer integration suite (`tests/assembly-integration.rs`). the assembly-layer integration suite (`tests/assembly-integration.rs`).
- **Fuzzing (`fuzz/` workspace, `docs/research/fuzzing.md`)** — the - **Fuzzing (`fuzz/` workspace, `docs/research/fuzzing.md`)**: the
alkcall §5 pattern adopted for this crate's two attacker-reachable alkcall §5 pattern adopted for this crate's two attacker-reachable
parser classes: `datagram_codec` (the `wire` codec — no-panic, parser classes: `datagram_codec` (the `wire` codec: no-panic,
infallible decode, size bound, re-frame round-trip, the infallible decode, size bound, re-frame round-trip, the
`retained_bytes` bound) and `rfc1928_codec` (the RFC 1928 handshake `retained_bytes` bound) and `rfc1928_codec` (the RFC 1928 handshake
state machine, fed attacker bytes from both protocol directions), state machine, fed attacker bytes from both protocol directions),
plus `params_parse`. Invariant logic lives in `fuzz/shared/` — a plus `params_parse`. Invariant logic lives in `fuzz/shared/`: a
stable-toolchain crate replayed as plain `cargo test` (the corpus stable-toolchain crate replayed as plain `cargo test` (the corpus
replay is the fuzz gate). 129 committed seeds; detached campaigns replay is the fuzz gate). 129 committed seeds; detached campaigns
clean (`oom/timeout/crash: 0/0/0`, coverage saturated). The one clean (`oom/timeout/crash: 0/0/0`, coverage saturated). The one
@@ -218,7 +247,8 @@ campaigns clean, no crate bugs.
[`AssociateSession`]: https://docs.rs/alksocks/latest/alksocks/consumer/struct.AssociateSession.html [`AssociateSession`]: https://docs.rs/alksocks/latest/alksocks/consumer/struct.AssociateSession.html
[`BindSession`]: https://docs.rs/alksocks/latest/alksocks/consumer/struct.BindSession.html [`BindSession`]: https://docs.rs/alksocks/latest/alksocks/consumer/struct.BindSession.html
[Unreleased]: https://git.alk.dev/alkdev/alksocks/compare/v0.3.0...HEAD [Unreleased]: https://git.alk.dev/alkdev/alksocks/compare/v0.3.1...HEAD
[0.3.2]: https://git.alk.dev/alkdev/alksocks/compare/v0.3.1...v0.3.2
[0.3.1]: https://git.alk.dev/alkdev/alksocks/compare/v0.3.0...v0.3.1 [0.3.1]: https://git.alk.dev/alkdev/alksocks/compare/v0.3.0...v0.3.1
[0.3.0]: https://git.alk.dev/alkdev/alksocks/compare/v0.2.0...v0.3.0 [0.3.0]: https://git.alk.dev/alkdev/alksocks/compare/v0.2.0...v0.3.0
[0.2.0]: https://git.alk.dev/alkdev/alksocks/compare/v0.1.0...v0.2.0 [0.2.0]: https://git.alk.dev/alkdev/alksocks/compare/v0.1.0...v0.2.0
+9 -9
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "alksocks" name = "alksocks"
version = "0.3.1" version = "0.3.2"
edition = "2021" edition = "2021"
rust-version = "1.88" rust-version = "1.88"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -29,14 +29,14 @@ composed = ["dep:alktunnels"]
[dependencies] [dependencies]
# The substrate (convention 11): pinned, bump deliberately, never # The substrate (convention 11): pinned, bump deliberately, never
# transitively. 0.10.1: the adoption-window ordering fix (review 013, # transitively. 0.10.1: the adoption-window ordering fix (review 013,
# routed from alkgit's wave-2 gate) — install+park-drain is one atomic # routed from alkgit's wave-2 gate): install+park-drain is one atomic
# section in the channels demux; the fix surface is private upstream, so # section in the channels demux; the fix surface is private upstream, so
# no change on this crate's paths (relevant in principle to the # no change on this crate's paths (relevant in principle to the
# bind-first adopt flow; the behavior only strengthens). # bind-first adopt flow; the behavior only strengthens).
alkcall = "0.10.1" alkcall = "0.10.1"
tokio = { version = "1", default-features = false, features = ["rt", "sync", "io-util", "macros", "time"] } tokio = { version = "1", default-features = false, features = ["rt", "sync", "io-util", "macros", "time"] }
# The fork's declared logging seam (rfc1928.md: upstream `log` calls # The fork's declared logging seam (rfc1928.md: upstream `log` calls
# were dropped or re-pointed here); no source file references it yet — # were dropped or re-pointed here); no source file references it yet,
# the first consumer is expected to be the producer layer's # the first consumer is expected to be the producer layer's
# diagnostics (review 001 N-2). # diagnostics (review 001 N-2).
tracing = "0.1" tracing = "0.1"
@@ -45,17 +45,17 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
bytes = "1" bytes = "1"
futures = { version = "0.3", default-features = false, features = ["std"] } futures = { version = "0.3", default-features = false, features = ["std"] }
# The sibling crate (the composed egress's direct-op vehicle — ADR-008/ # The sibling crate (the composed egress's direct-op vehicle, ADR-008/
# ADR-009): rides `composed` alone. Pinned; bump deliberately # ADR-009): rides `composed` alone. Pinned; bump deliberately
# (convention 11's deliberate-bump rule applies to siblings too). # (convention 11's deliberate-bump rule applies to siblings too).
# 0.4.1: the dependency-patch ride on alkcall 0.10.1, no alktunnels # 0.5.0: the correlation-gate release (the forwarded op's serving half
# API/wire change. # gains an establishment phase (additive API only, no wire change).
alktunnels = { version = "0.4.1", optional = true } alktunnels = { version = "0.5.0", optional = true }
[dev-dependencies] [dev-dependencies]
tokio = { version = "1", features = ["full", "test-util", "macros"] } tokio = { version = "1", features = ["full", "test-util", "macros"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# The composed suite's topology (the alktunnels direct-op producer) — # The composed suite's topology (the alktunnels direct-op producer);
# dev-only: the test binary gates itself on the `composed` feature # dev-only: the test binary gates itself on the `composed` feature
# (`#![cfg(feature = "composed")]`), the dev-dep keeps it compiling. # (`#![cfg(feature = "composed")]`), the dev-dep keeps it compiling.
alktunnels = "0.4.1" alktunnels = "0.5.0"
+28 -70
View File
@@ -2,7 +2,7 @@
SOCKS5 (RFC 1928) as a produced resource on SOCKS5 (RFC 1928) as a produced resource on
[alkcall](https://crates.io/crates/alkcall) channels: the `ssh -D` [alkcall](https://crates.io/crates/alkcall) channels: the `ssh -D`
(dynamic / SOCKS5 proxy) flavor as a first-class protocol crate — (dynamic / SOCKS5 proxy) flavor as a first-class protocol crate;
the sibling of [alktty](https://crates.io/crates/alktty) (terminals) the sibling of [alktty](https://crates.io/crates/alktty) (terminals)
and [alktunnels](https://crates.io/crates/alktunnels) (`-L`/`-R` and [alktunnels](https://crates.io/crates/alktunnels) (`-L`/`-R`
tunnels). The RFC 1928 conversation rides inside a channels data tunnels). The RFC 1928 conversation rides inside a channels data
@@ -11,15 +11,15 @@ the producing side.
The defining posture: **the protocol never binds a port unless The defining posture: **the protocol never binds a port unless
explicitly configured to.** Kernel binds exist only as explicit, explicitly configured to.** Kernel binds exist only as explicit,
feature-gated assembly shapes on either side — the consumer may feature-gated assembly shapes on either side. The consumer may
expose the resource on a local port (the front door vanilla clients — expose the resource on a local port, the front door vanilla clients
curl, browsers, tun2proxy — point at), and the producer may serve (curl, browsers, tun2proxy) point at, and the producer may serve
from a real listener. A producer/consumer protocol crate, no from a real listener. A producer/consumer protocol crate, no
transport dependencies; the transport is whatever alkcall runs over. transport dependencies; the transport is whatever alkcall runs over.
## Quick start ## Quick start
### Producer — register the SOCKS5 open op ### Producer: register the SOCKS5 open op
```rust ```rust
use std::sync::Arc; use std::sync::Arc;
@@ -49,11 +49,11 @@ let op_registry = Arc::new(OperationRegistry::new());
// session-gate establisher checks the opener identity, and the pump // session-gate establisher checks the opener identity, and the pump
// handler drives the RFC 1928 state machine in-stream over the // handler drives the RFC 1928 state machine in-stream over the
// channel's `BiStream`. Wire `core` + `op_registry` into your // channel's `BiStream`. Wire `core` + `op_registry` into your
// channels serving stack — this call is the socks5-specific step: // channels serving stack; this call is the socks5-specific step:
producer.register(&core, &op_registry, AuthContext::anonymous(b"alk/socks5"), None)?; producer.register(&core, &op_registry, AuthContext::anonymous(b"alk/socks5"), None)?;
``` ```
### Consumer — open a SOCKS5 channel (CONNECT) ### Consumer: open a SOCKS5 channel (CONNECT)
```rust ```rust
use alkcall::channels::client::ChannelClient; use alkcall::channels::client::ChannelClient;
@@ -64,7 +64,7 @@ let client: ChannelClient = /* your alk/channels connection */;
// One session = one channel = one SOCKS5 session. The RFC handshake // One session = one channel = one SOCKS5 session. The RFC handshake
// runs in-stream over the channel; a refused target surfaces the RFC // runs in-stream over the channel; a refused target surfaces the RFC
// reply code (typed errors, no phantom channel — the adopted channel // reply code (typed errors with no phantom channel: the adopted channel
// is torn down before the error returns). // is torn down before the error returns).
let mut session = Socks5ChannelSession::connect( let mut session = Socks5ChannelSession::connect(
&client, &client,
@@ -75,14 +75,14 @@ let (mut read, mut write) = session.split().expect("pipe");
// ... raw pass-through: the channel IS the target connection ... // ... raw pass-through: the channel IS the target connection ...
``` ```
### Consumer — UDP ASSOCIATE (the framed datagram stage) ### Consumer: UDP ASSOCIATE (the framed datagram stage)
```rust ```rust
use alksocks::AssociateSession; use alksocks::AssociateSession;
use alksocks::rfc1928::util::target_addr::TargetAddr; use alksocks::rfc1928::util::target_addr::TargetAddr;
// One channel per association; datagrams ride `[len: u16 BE] // One channel per association; datagrams ride `[len: u16 BE]
// [addr + data]` frames in-band (ADR-003) — the RFC codec exists at // [addr + data]` frames in-band (ADR-003); the RFC codec exists at
// the front doors only. // the front doors only.
let mut assoc = AssociateSession::associate(&client).await?; let mut assoc = AssociateSession::associate(&client).await?;
let dest = TargetAddr::Domain { addr: "dns.internal".into(), port: 53 }; let dest = TargetAddr::Domain { addr: "dns.internal".into(), port: 53 };
@@ -90,14 +90,14 @@ assoc.send_datagram(&dest, b"query").await?;
let (src, payload) = assoc.recv_datagram().await?.expect("a datagram"); let (src, payload) = assoc.recv_datagram().await?.expect("a datagram");
``` ```
### Consumer — BIND (the reply#1/reply#2 reader) ### Consumer: BIND (the reply#1/reply#2 reader)
```rust ```rust
use alksocks::BindSession; use alksocks::BindSession;
use alksocks::rfc1928::util::target_addr::TargetAddr; use alksocks::rfc1928::util::target_addr::TargetAddr;
// Requires the `socks5:bind` grant on the producing side (a separate // Requires the `socks5:bind` grant on the producing side (a separate
// capability from `socks5:open` — never implied, ADR-006). The // capability from `socks5:open`; never implied, ADR-006). The
// session drives the phase model: reply#1 → quiet wait → exactly one // session drives the phase model: reply#1 → quiet wait → exactly one
// accept → reply#2 → raw pass-through (ADR-007). // accept → reply#2 → raw pass-through (ADR-007).
let mut bind = BindSession::bind(&client, TargetAddr::Ip("0.0.0.0:0".parse()?)).await?; let mut bind = BindSession::bind(&client, TargetAddr::Ip("0.0.0.0:0".parse()?)).await?;
@@ -105,7 +105,7 @@ let reply1 = bind.reply1().await?; // the BND.ADDR the producer bound
// ... hand reply1's address to the peer, await the accept + reply#2 ... // ... hand reply1's address to the peer, await the accept + reply#2 ...
``` ```
### Consumer — the front door (vanilla clients) ### Consumer: the front door (vanilla clients)
```rust ```rust
use std::sync::Arc; use std::sync::Arc;
@@ -127,15 +127,15 @@ let door = FrontDoor::builder()
door.serve(client).await?; door.serve(client).await?;
``` ```
### Producer — the local listener shape (the other door) ### Producer: the local listener shape (the other door)
```rust ```rust
use alksocks::producer::Socks5Producer; use alksocks::producer::Socks5Producer;
// Feature `local`: serve a produced SOCKS5 resource from a real // Feature `local`: serve a produced SOCKS5 resource from a real
// listener — vanilla clients dial it, each accepted connection is // listener; vanilla clients dial it, each accepted connection is
// served via `serve_socks5_over` (the NoAuth in-stream RFC // served via `serve_socks5_over` (the NoAuth in-stream RFC
// conversation; the seams dial locally). The bind is explicit — the // conversation; the seams dial locally). The bind is explicit: the
// builder requires it; the protocol layer never binds. // builder requires it; the protocol layer never binds.
let producer = Socks5Producer::builder() let producer = Socks5Producer::builder()
.dial(alksocks::local::tcp_dial()) .dial(alksocks::local::tcp_dial())
@@ -154,8 +154,8 @@ both sides of a connection can be either; role follows the resource):
| Role | Via `alk/channels` | | Role | Via `alk/channels` |
|------|--------------------| |------|--------------------|
| **Producer** (dials targets, serves the RFC conversation) | [`Socks5Producer`](https://docs.rs/alksocks/latest/alksocks/producer/struct.Socks5Producer.html) / [`register_socks5_openable`](https://docs.rs/alksocks/latest/alksocks/producer/fn.register_socks5_openable.html) — the open op (`channels/socks5/sub`) with the session-gate establisher and the command-dispatch pump; the RFC 1928 typestate core drives negotiation, command read, and replies in-stream over the channel's `BiStream`; CONNECT/BIND data planes ride [`alkcall::channels::pump_bidi`](https://docs.rs/alkcall/latest/alkcall/channels/pump/fn.pump_bidi.html) | | **Producer** (dials targets, serves the RFC conversation) | [`Socks5Producer`](https://docs.rs/alksocks/latest/alksocks/producer/struct.Socks5Producer.html) / [`register_socks5_openable`](https://docs.rs/alksocks/latest/alksocks/producer/fn.register_socks5_openable.html): the open op (`channels/socks5/sub`) with the session-gate establisher and the command-dispatch pump; the RFC 1928 typestate core drives negotiation, command read, and replies in-stream over the channel's `BiStream`; CONNECT/BIND data planes ride [`alkcall::channels::pump_bidi`](https://docs.rs/alkcall/latest/alkcall/channels/pump/fn.pump_bidi.html) |
| **Consumer** (wants the proxy) | [`Socks5ChannelSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.Socks5ChannelSession.html) (CONNECT) / [`AssociateSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.AssociateSession.html) (ASSOCIATE) / [`BindSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.BindSession.html) (BIND) — typed channels-native sessions, each owning its channel's teardown | | **Consumer** (wants the proxy) | [`Socks5ChannelSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.Socks5ChannelSession.html) (CONNECT) / [`AssociateSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.AssociateSession.html) (ASSOCIATE) / [`BindSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.BindSession.html) (BIND): typed channels-native sessions, each owning its channel's teardown |
One connection hosts many SOCKS5 sessions (one per channel); sessions One connection hosts many SOCKS5 sessions (one per channel); sessions
are independent. Both roles are callable from either connection side are independent. Both roles are callable from either connection side
@@ -164,12 +164,12 @@ client/server roles keep their RFC names inside the protocol layer.
### The RFC conversation is the data plane ### The RFC conversation is the data plane
The open op's params are deliberately trivial (`{}`, ADR-001) — The open op's params are deliberately trivial (`{}`, ADR-001);
there is nothing to negotiate at open time (the SOCKS5 target is there is nothing to negotiate at open time (the SOCKS5 target is
unknown until the RFC command arrives). Everything else is RFC 1928, unknown until the RFC command arrives). Everything else is RFC 1928,
in-stream: in-stream:
- **CONNECT**: raw pass-through, 0 B overhead — the channel's halves - **CONNECT**: raw pass-through, 0 B overhead; the channel's halves
are the target connection (the two-pump shape). are the target connection (the two-pump shape).
- **ASSOCIATE**: the reply's BND.ADDR is a pure RFC handle (the - **ASSOCIATE**: the reply's BND.ADDR is a pure RFC handle (the
sentinel in-band); datagrams ride `[len: u16 BE][addr + data]` sentinel in-band); datagrams ride `[len: u16 BE][addr + data]`
@@ -182,7 +182,7 @@ in-stream:
Authorization is two grants, never implied (ADR-006): the open gate Authorization is two grants, never implied (ADR-006): the open gate
(`socks5:open`) at the registry, the command gate (`socks5:bind`) at (`socks5:open`) at the registry, the command gate (`socks5:bind`) at
BIND dispatch. The target policy is the dialer (ADR-012): the BIND dispatch. The target policy is the dialer (ADR-012): the
producer's dial/egress/accept closures are the security boundary — producer's dial/egress/accept closures are the security boundary;
whatever they allow is what the resource reaches. whatever they allow is what the resource reaches.
### The seams ### The seams
@@ -191,70 +191,28 @@ The protocol layer is substrate-agnostic by construction (ADR-004):
`DialFn` / `EgressFactory` / `BindAcceptFactory` are *functions, not `DialFn` / `EgressFactory` / `BindAcceptFactory` are *functions, not
traits* at the three I/O planes; ownership rides the handle traits* at the three I/O planes; ownership rides the handle
structurally. A channels `BiStream`, a `TcpStream`, or an in-process structurally. A channels `BiStream`, a `TcpStream`, or an in-process
pipe drops in behind the same seams — no socket type leaks into the pipe drops in behind the same seams; no socket type leaks into the
protocol layer. protocol layer.
### WASM target ### WASM target
The default crate (no features) compiles to `wasm32-unknown-unknown` The default crate (no features) compiles to `wasm32-unknown-unknown`,
— the protocol layer for a sandboxed TS/Python adapter. The RFC 1928 the protocol layer for a sandboxed TS/Python adapter. The RFC 1928
core is a targeted fork of core is a targeted fork of
[fast-socks5](https://github.com/dizda/fast-socks5) v1.0.0's [fast-socks5](https://github.com/dizda/fast-socks5) v1.0.0's
`T`-generic subset, extracted in-tree as fully owned code `T`-generic subset, extracted in-tree as fully owned code
(`src/rfc1928/`, MIT notice bundled; ADR-011/013): no kernel-net (`src/rfc1928/`, MIT notice bundled; ADR-011/013): no kernel-net
code, no `net` feature — the socket I/O rides `local` alone, and the code, no `net` feature; the socket I/O rides `local` alone, and the
default build is wasm-clean by construction. default build is wasm-clean by construction.
## Features ## Features
| Feature | Contents | wasm | | Feature | Contents | wasm |
|---------|----------|------| |---------|----------|------|
| *(default)* | the RFC 1928 core (`src/rfc1928/` — codecs, typestate, client `use_stream`), the ASSOCIATE wire codec, the open-op spec, the three seams, the producer + consumer session types — protocol only | yes | | *(default)* | the RFC 1928 core (`src/rfc1928/`: codecs, typestate, client `use_stream`), the ASSOCIATE wire codec, the open-op spec, the three seams, the producer + consumer session types; protocol only | yes |
| `local` | the socket-backed seam providers ([`tcp_dial`](https://docs.rs/alksocks/latest/alksocks/local/fn.tcp_dial.html) / [`udp_egress`](https://docs.rs/alksocks/latest/alksocks/local/fn.udp_egress.html) / [`bind_accept`](https://docs.rs/alksocks/latest/alksocks/local/fn.bind_accept.html)), the front doors ([`FrontDoor`](https://docs.rs/alksocks/latest/alksocks/door/struct.FrontDoor.html) + [`UdpFrontDoor`](https://docs.rs/alksocks/latest/alksocks/door/struct.UdpFrontDoor.html), the consumer-side exposure; [`ProducerDoor`](https://docs.rs/alksocks/latest/alksocks/local/producer_door/struct.ProducerDoor.html), the producer-side listener) | no | | `local` | the socket-backed seam providers ([`tcp_dial`](https://docs.rs/alksocks/latest/alksocks/local/fn.tcp_dial.html) / [`udp_egress`](https://docs.rs/alksocks/latest/alksocks/local/fn.udp_egress.html) / [`bind_accept`](https://docs.rs/alksocks/latest/alksocks/local/fn.bind_accept.html)), the front doors ([`FrontDoor`](https://docs.rs/alksocks/latest/alksocks/door/struct.FrontDoor.html) + [`UdpFrontDoor`](https://docs.rs/alksocks/latest/alksocks/door/struct.UdpFrontDoor.html), the consumer-side exposure; [`ProducerDoor`](https://docs.rs/alksocks/latest/alksocks/local/producer_door/struct.ProducerDoor.html), the producer-side listener) | no |
| `composed` | the alktunnels-composed providers: [`direct_egress`](https://docs.rs/alksocks/latest/alksocks/composed/fn.direct_egress.html) (the direct-op egress, a lazy destination→tunnel table) + [`bindfirst_accept`](https://docs.rs/alksocks/latest/alksocks/composed/fn.bindfirst_accept.html) (the BIND accept whose listener lives on a bind-first listen resource) | yes | | `composed` | the alktunnels-composed providers: [`direct_egress`](https://docs.rs/alksocks/latest/alksocks/composed/fn.direct_egress.html) (the direct-op egress, a lazy destination→tunnel table) + [`bindfirst_accept`](https://docs.rs/alksocks/latest/alksocks/composed/fn.bindfirst_accept.html) (the BIND accept whose listener lives on a bind-first listen resource) | yes |
## Version 0.1
The first release rides alkcall 0.8.1 (the establisher reply
projection is not consumed yet — the open op's params are trivial;
the 0.8.1 duplicate-adopt fix is directly relevant to the consumer
open/adopt paths) and alktunnels 0.2.1 (the direct op + the
bind-first/forwarded flow power the composed providers). The
`fuzz/` workspace (excluded from the published package) fuzzes the
two attacker-reachable parser classes — the ASSOCIATE datagram codec
and the RFC 1928 handshake state machine. Wire-stable surface: the
`alk/socks5` ALPN, the `channels/socks5/sub` open op with `{}` params,
and the two scope constants — all pinned by ADRs 001–014.
0.2.0 rides the alkcall bump 0.8.1 → 0.9.0 (the stub-sweep release —
the four registered-but-always-failing stubs became real
implementations; none were on this crate's paths) and the alktunnels
bump 0.2.1 → 0.3.0. No alksocks API change, no wire change, zero code
contact with the 0.9.0 breaking surface; minor rather than patch
contact with the 0.9.0 breaking surface; minor rather than patch
because the semver-incompatible dependency bumps + this crate's
alkcall type re-exports make it semver-visible downstream.
0.3.0 rides the alkcall bump 0.9.0 → 0.10.0 (the gateway
ownership-seam release — `GatewayDispatch` ownership-provider
threading and the loud unwired-spine default; the gateway module
isn't on this crate's paths, registry dispatch on the native wire is
untouched) and the alktunnels bump 0.3.0 → 0.4.0 (the dependency-major
ride on alkcall 0.10.0). No alksocks API change, no wire change, zero
code contact with the 0.10.0 breaking surface (the only contact is the
fuzz/shared harness's own alkcall pin — a mechanical lockstep bump);
minor rather than patch for the same semver-visible-dependency reason
as 0.2.0.
0.3.1 rides the alkcall bump 0.10.0 → 0.10.1 (the
adoption-window ordering fix — a chunk routed inside the demux's
install→drain window could be delivered ahead of earlier-parked
early arrivals, inverting byte-stream order; relevant in principle
to the bind-first adopt flow) and the alktunnels bump 0.4.0 → 0.4.1
(its dependency-patch ride). The fix surface is private upstream, so
there is no change on this crate's paths; patch rather than minor
because both dependency bumps are semver-compatible.
## Verification ## Verification
```bash ```bash
@@ -268,11 +226,11 @@ cargo check --target wasm32-unknown-unknown # the wasm-clean guard
## Documentation ## Documentation
- [Architecture docs](docs/architecture/README.md) — the - [Architecture docs](docs/architecture/README.md): the
authoritative spec set (in the repository, not the published authoritative spec set (in the repository, not the published
package): ADRs (001–014), the wire format, the producer/consumer package): ADRs (001–014), the wire format, the producer/consumer
contracts, and the fork provenance. contracts, and the fork provenance.
- [API docs](https://docs.rs/alksocks) — full crate documentation on - [API docs](https://docs.rs/alksocks): full crate documentation on
docs.rs. docs.rs.
## License ## License
+35 -35
View File
@@ -4,15 +4,15 @@ last_updated: 2026-09-25 (the assembly layer landed + the cross-cutting
assembly integration suite; the protocol core landed + the cross-cutting assembly integration suite; the protocol core landed + the cross-cutting
integration suite 2026-09-24; the rfc1928 doc's targeted fork 2026-09-22; the integration suite 2026-09-24; the rfc1928 doc's targeted fork 2026-09-22; the
vendor extraction amendment ADR-011 §Amendment 2026-09-21; Phase 1 vendor extraction amendment ADR-011 §Amendment 2026-09-21; Phase 1
initial draft 2026-09-20 — the OQ-SK ledger promoted into ADRs initial draft 2026-09-20; the OQ-SK ledger promoted into ADRs
001-012 and open-questions.md) 001-012 and open-questions.md)
--- ---
# alksocks — Architecture # alksocks: Architecture
A SOCKS5 (RFC 1928) producer/consumer protocol crate on alkcall A SOCKS5 (RFC 1928) producer/consumer protocol crate on alkcall
channels: an arbitrary-egress proxy service that never binds a port channels: an arbitrary-egress proxy service that never binds a port
unless explicitly configured to — the `alk/socks5`-family unless explicitly configured to. It is the `alk/socks5`-family
producer/consumer protocol crate, the sibling of alktty producer/consumer protocol crate, the sibling of alktty
(`alk/tty`) and alktunnels (`alk/tunnel`). Where alktunnels (`alk/tty`) and alktunnels (`alk/tunnel`). Where alktunnels
realizes the `-L`/`-R` forwarding flavors, alksocks realizes the realizes the `-L`/`-R` forwarding flavors, alksocks realizes the
@@ -21,34 +21,34 @@ architecture spec docs and the ADRs.
## Current State ## Current State
Phase 1 (Architecture) — the **protocol core and the assembly layer Phase 1 (Architecture): the **protocol core and the assembly layer
are implemented**. Phase are implemented**. Phase
0 (`docs/research/phase-0.md`) is converged (2026-09-14) and 0 (`docs/research/phase-0.md`) is converged (2026-09-14) and
POC-complete (all numbered POCs passed or were dropped with the scope POC-complete (all numbered POCs passed or were dropped with the scope
decision); the OQ-SK ledger's Phase 1 inputs are collected and decision); the OQ-SK ledger's Phase 1 inputs are collected and
promoted into ADRs 001–014. All docs are `draft` — they transition promoted into ADRs 001–014. All docs are `draft`; they transition
to `reviewed` when their open questions resolve and the architecture to `reviewed` when their open questions resolve and the architecture
review passes. review passes.
The landed surface: the RFC 1928 core in `src/rfc1928/` (the targeted The landed surface: the RFC 1928 core in `src/rfc1928/` (the targeted
fork, ADR-013 — the vendor group's six tasks landed and passed the fork, ADR-013; the vendor group's six tasks landed and passed the
extraction review), the ASSOCIATE datagram frame codec (`src/wire.rs`, extraction review), the ASSOCIATE datagram frame codec (`src/wire.rs`,
ADR-003), the open-op params + typed error surface (`src/params.rs` / ADR-003), the open-op params + typed error surface (`src/params.rs` /
`src/error.rs`, ADR-001), the three seam types (`src/egress.rs`, `src/error.rs`, ADR-001), the three seam types (`src/egress.rs`,
ADR-004), the producer half (`src/producer.rs` — the session-gate ADR-004), the producer half (`src/producer.rs`: the session-gate
establisher, the command-dispatch pump, the three commands, the scope establisher, the command-dispatch pump, the three commands, the scope
gates), and the consumer half (`src/consumer.rs` — the gates), and the consumer half (`src/consumer.rs`: the
connect/associate/bind session types with teardown ownership). The connect/associate/bind session types with teardown ownership). The
assembly layer (batch 2) landed with it: the `local` providers assembly layer (batch 2) landed with it: the `local` providers in
(`src/local/` — tcp_dial / udp_egress / bind_accept, the producer-side `src/local/` (tcp_dial / udp_egress / bind_accept, the producer-side
`ProducerDoor`, ADR-008/009), the front doors (`src/door.rs` — the `ProducerDoor`, ADR-008/009), the front doors in `src/door.rs` (the
NoAuth raw-pipe + RFC 1929 cred-bearing TCP door, the BIND door, the NoAuth raw-pipe + RFC 1929 cred-bearing TCP door, the BIND door, the
UDP door, ADR-005/003/014), and the composed providers UDP door, ADR-005/003/014), and the composed providers in
(`src/composed/` — the direct-op egress + the bind-first accept over `src/composed/` (the direct-op egress + the bind-first accept over
alktunnels 0.2.0, ADR-007/008). Both cross-cutting integration suites alktunnels 0.2.0, ADR-007/008). Both cross-cutting integration suites
pin the behavior end to end: `tests/core-integration.rs` (the pin the behavior end to end: `tests/core-integration.rs` (the
protocol core) and `tests/assembly-integration.rs` (the assembly protocol core) and `tests/assembly-integration.rs` (the assembly
layer — the full `-D` story through the doors, the composed variant layer: the full `-D` story through the doors, the composed variant
set, mixed assembly, the assembly teardown matrix). set, mixed assembly, the assembly teardown matrix).
## Documents ## Documents
@@ -56,33 +56,33 @@ set, mixed assembly, the assembly teardown matrix).
| Document | Status | Description | | Document | Status | Description |
|----------|--------|-------------| |----------|--------|-------------|
| [overview.md](overview.md) | draft | Crate purpose, the resource model in brief, dependencies, feature gates, module map | | [overview.md](overview.md) | draft | Crate purpose, the resource model in brief, dependencies, feature gates, module map |
| [wire.md](wire.md) | draft | The wire format: the open op (trivial params — ADR-001), the phase model, the data planes (CONNECT pass-through / ASSOCIATE frames / BIND phases), sentinels | | [wire.md](wire.md) | draft | The wire format: the open op (trivial params, ADR-001), the phase model, the data planes (CONNECT pass-through / ASSOCIATE frames / BIND phases), sentinels |
| [producer.md](producer.md) | draft | The producer half: the session-gate establisher, command dispatch, the three seams (dial/egress/accept), scope gates, the local backend | | [producer.md](producer.md) | draft | The producer half: the session-gate establisher, command dispatch, the three seams (dial/egress/accept), scope gates, the local backend |
| [consumer.md](consumer.md) | draft | The consumer half: the channels-native sessions (connect/associate/bind), the front doors, door auth (ADR-005), teardown ownership | | [consumer.md](consumer.md) | draft | The consumer half: the channels-native sessions (connect/associate/bind), the front doors, door auth (ADR-005), teardown ownership |
| [vendor.md](rfc1928.md) | draft | The RFC 1928 core: the targeted fork of fast-socks5's `T`-generic subset (ADR-013; the extraction mechanics in ADR-011) — the fork's scope, the deliberate modifications, the maintenance posture | | [vendor.md](rfc1928.md) | draft | The RFC 1928 core: the targeted fork of fast-socks5's `T`-generic subset (ADR-013; the extraction mechanics in ADR-011), covering the fork's scope, the deliberate modifications, the maintenance posture |
| [bast.md](bast.md) | draft | The BAST document for the ASSOCIATE datagram frame (ADR-003); pass-through stages have no binary framing to describe | | [bast.md](bast.md) | draft | The BAST document for the ASSOCIATE datagram frame (ADR-003); pass-through stages have no binary framing to describe |
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-SK-01..09 promoted; new questions as OQ-SK2-06.. — the SK2 numbering is not contiguous from 01, working-draft numbers were consumed) | | [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-SK-01..09 promoted; new questions as OQ-SK2-06.., where the SK2 numbering is not contiguous from 01 because working-draft numbers were consumed) |
## ADR Table ## ADR Table
| ADR | Title | Status | | ADR | Title | Status |
|-----|-------|--------| |-----|-------|--------|
| [001](decisions/001-alpn-and-open-op.md) | ALPN, Open Op, and Trivial Params | Accepted | | [001](decisions/001-alpn-and-open-op.md) | ALPN, Open Op, and Trivial Params | Accepted |
| [002](decisions/002-protocol-base.md) | Protocol Base — the Vendored fast-socks5 Typestate Subset | Accepted | | [002](decisions/002-protocol-base.md) | Protocol Base: the Vendored fast-socks5 Typestate Subset | Accepted |
| [003](decisions/003-udp-handle-swap.md) | The Handle Swap — one Channel per Association, Frames In-Band | Accepted | | [003](decisions/003-udp-handle-swap.md) | The Handle Swap: one Channel per Association, Frames In-Band | Accepted |
| [004](decisions/004-dial-callback-shape.md) | The Dial-Callback Shape — Function, not Trait, at Three Seams | Accepted | | [004](decisions/004-dial-callback-shape.md) | The Dial-Callback Shape: Function, not Trait, at Three Seams | Accepted |
| [005](decisions/005-auth-split-by-door.md) | Auth Mapping — "Both," Split by the Door | Accepted | | [005](decisions/005-auth-split-by-door.md) | Auth Mapping: "Both," Split by the Door | Accepted |
| [006](decisions/006-access-control.md) | Access-Control Posture — the Open Gate Plus the Command Gate | Accepted | | [006](decisions/006-access-control.md) | Access-Control Posture: the Open Gate Plus the Command Gate | Accepted |
| [007](decisions/007-bind-in-base.md) | BIND in the Base Crate — the Phase Model Extended | Accepted | | [007](decisions/007-bind-in-base.md) | BIND in the Base Crate: the Phase Model Extended | Accepted |
| [008](decisions/008-provider-defaults.md) | Egress/Accept Provider Defaults — Local Socket and Composed (Direct Op) | Accepted | | [008](decisions/008-provider-defaults.md) | Egress/Accept Provider Defaults: Local Socket and Composed (Direct Op) | Accepted |
| [009](decisions/009-feature-gates.md) | Feature-Gate Layout — `local` and the Composition Boundary | Accepted (amended 2026-09-21) | | [009](decisions/009-feature-gates.md) | Feature-Gate Layout: `local` and the Composition Boundary | Accepted (amended 2026-09-21) |
| [010](decisions/010-channel-capacity.md) | Channel Capacity — Proxy-Workload Recommendation, Not a Wire Change | Accepted | | [010](decisions/010-channel-capacity.md) | Channel Capacity: Proxy-Workload Recommendation, Not a Wire Change | Accepted |
| [011](decisions/011-inline-vendor.md) | the Inline Vendor — fast-socks5's `T`-Generic Subset, In-Tree | Superseded by ADR-013 (extraction mechanics remain authoritative) | | [011](decisions/011-inline-vendor.md) | the Inline Vendor: fast-socks5's `T`-Generic Subset, In-Tree | Superseded by ADR-013 (extraction mechanics remain authoritative) |
| [012](decisions/012-target-policy.md) | Target Policy and the Virtual-Subnet Translation Layer | Accepted | | [012](decisions/012-target-policy.md) | Target Policy and the Virtual-Subnet Translation Layer | Accepted |
| [013](decisions/013-targeted-fork.md) | The Targeted Fork — the Extracted Subset Is Owned, Not Vendored | Accepted | | [013](decisions/013-targeted-fork.md) | The Targeted Fork: the Extracted Subset Is Owned, Not Vendored | Accepted |
Upstream ADRs (alkcall/alktunnels/alknet) are referenced by their own Upstream ADRs (alkcall/alktunnels/alknet) are referenced by their own
numbers and are NOT ported — the alkcall originals at numbers and are NOT ported; the alkcall originals at
`/workspace/@alkdev/alkcall/docs/architecture/decisions/` remain `/workspace/@alkdev/alkcall/docs/architecture/decisions/` remain
authoritative for the shared surfaces (establishment, channels wire authoritative for the shared surfaces (establishment, channels wire
format, `pump_bidi`, identity seam, the channel caps). format, `pump_bidi`, identity seam, the channel caps).
@@ -98,7 +98,7 @@ format, `pump_bidi`, identity seam, the channel caps).
(the handle swap, ADR-003). (the handle swap, ADR-003).
2. **The RFC conversation is the data plane.** One ALPN, one open 2. **The RFC conversation is the data plane.** One ALPN, one open
op, trivial params (ADR-001); the phase model needs no mode byte op, trivial params (ADR-001); the phase model needs no mode byte
— the reply synchronizes the switch; no in-band control (the reply synchronizes the switch); no in-band control
vocabulary (the escape hatch is a new ALPN, never a framing vocabulary (the escape hatch is a new ALPN, never a framing
change). change).
3. **The seams are functions, not traits** (ADR-004): DialFn / 3. **The seams are functions, not traits** (ADR-004): DialFn /
@@ -109,7 +109,7 @@ format, `pump_bidi`, identity seam, the channel caps).
seam; the front door = RFC 1929 + the credential→identity static seam; the front door = RFC 1929 + the credential→identity static
mapping. mapping.
5. **Two grants, clean separation** (ADR-006): `socks5:open` 5. **Two grants, clean separation** (ADR-006): `socks5:open`
(egress) and `socks5:bind` (ingress listeners) — never implied by (egress) and `socks5:bind` (ingress listeners), never implied by
each other; target policy = the dialer. each other; target policy = the dialer.
6. **Backpressure and limits are inherited, not redefined** (the 6. **Backpressure and limits are inherited, not redefined** (the
alkcall channels invariants); the one-channel-per-session alkcall channels invariants); the one-channel-per-session
@@ -117,14 +117,14 @@ format, `pump_bidi`, identity seam, the channel caps).
posture is ADR-010 (config, not wire). posture is ADR-010 (config, not wire).
7. **Wasm-clean default crate** (ADR-009): the default crate is 7. **Wasm-clean default crate** (ADR-009): the default crate is
protocol-only; the RFC 1928 core is an extraction with zero protocol-only; the RFC 1928 core is an extraction with zero
kernel-net code — there is no `net` feature (ADR-011 §Amendment); kernel-net code; there is no `net` feature (ADR-011 §Amendment);
`local` is opt-in and non-wasm; `composed` rides alktunnels' `local` is opt-in and non-wasm; `composed` rides alktunnels'
wasm-clean default (verified with the wasm check at wasm-clean default (verified with the wasm check at
implementation). implementation).
8. **The RFC 1928 core is owned** (ADR-013, superseding ADR-011's 8. **The RFC 1928 core is owned** (ADR-013, superseding ADR-011's
vendor posture): the extracted `T`-generic subset is a targeted vendor posture): the extracted `T`-generic subset is a targeted
fork (`src/rfc1928/`, fork point `5b18d4d`) — project fork (`src/rfc1928/`, fork point `5b18d4d`); project
conventions apply unconditionally, the lint-allow/verbatim conventions apply unconditionally, and the lint-allow/verbatim
apparatus is dissolved; `/workspace/fast-socks5` stays the apparatus is dissolved; `/workspace/fast-socks5` stays the
differential-testing behavioral reference and the only place the differential-testing behavioral reference and the only place the
full upstream surface (the kernel-net region) exists; nothing full upstream surface (the kernel-net region) exists; nothing
@@ -152,5 +152,5 @@ format, `pump_bidi`, identity seam, the channel caps).
- Siblings: alktty (`/workspace/@alkdev/alktty`), - Siblings: alktty (`/workspace/@alkdev/alktty`),
alktunnels (`/workspace/@alkdev/alktunnels`) alktunnels (`/workspace/@alkdev/alktunnels`)
- Reference checkout: `/workspace/fast-socks5` (v1.0.0, MIT, - Reference checkout: `/workspace/fast-socks5` (v1.0.0, MIT,
third-party — the differential-testing reference) third-party; the differential-testing reference)
- RFC 1928, RFC 1929; tun2proxy (`/workspace/tun2proxy`) - RFC 1928, RFC 1929; tun2proxy (`/workspace/tun2proxy`)
+19 -19
View File
@@ -3,11 +3,11 @@ status: draft
last_updated: 2026-09-20 (Phase 1 initial draft) last_updated: 2026-09-20 (Phase 1 initial draft)
--- ---
# alksocks — BAST Document for the `alk/socks5` Wire Format # alksocks: BAST Document for the `alk/socks5` Wire Format
This document is the **BAST (Binary Abstract Syntax Tree)** This document is the **BAST (Binary Abstract Syntax Tree)**
specification for the binary portions of the `alk/socks5` wire specification for the binary portions of the `alk/socks5` wire
format — per the alk* family BAST convention (alktunnels AGENTS.md format, per the alk* family BAST convention (alktunnels AGENTS.md
convention 12, carried from alktty; alktunnels `bast.md` is the convention 12, carried from alktty; alktunnels `bast.md` is the
precedent), the mandatory ASSOCIATE precedent), the mandatory ASSOCIATE
datagram framing ([ADR-003](decisions/003-udp-handle-swap.md)) is datagram framing ([ADR-003](decisions/003-udp-handle-swap.md)) is
@@ -28,36 +28,36 @@ feeds it.
## Scope ## Scope
The `alk/socks5` wire format is: a trivial JSON open op on the The `alk/socks5` wire format is: a trivial JSON open op on the
channels call plane (a UTF-8 JSON object — `{}`; specified in channels call plane (a UTF-8 JSON object, `{}`; specified in
[wire.md](wire.md) §The Open Op and [wire.md](wire.md) §The Open Op and
[ADR-001](decisions/001-alpn-and-open-op.md)), and a data plane [ADR-001](decisions/001-alpn-and-open-op.md)), and a data plane
inside the channel `BiStream` that is *mostly* RFC 1928 wire rules inside the channel `BiStream` that is *mostly* RFC 1928 wire rules
plus raw pass-through. plus raw pass-through.
Per the BAST format spec, a BAST document describes **binary data Per the BAST format spec, a BAST document describes **binary data
layouts** — not JSON payloads. The RFC 1928 conversation itself layouts**, not JSON payloads. The RFC 1928 conversation itself
(negotiation, command, replies, the RFC UDP datagram header at front (negotiation, command, replies, the RFC UDP datagram header at front
doors) is specified by RFC 1928 and implemented by the forked doors) is specified by RFC 1928 and implemented by the forked
RFC 1928 codecs ([rfc1928.md](rfc1928.md)) — it is not re-specified RFC 1928 codecs ([rfc1928.md](rfc1928.md)); it is not re-specified
here. This document covers exactly the alksocks-specific binary here. This document covers exactly the alksocks-specific binary
framing: framing:
1. **The ASSOCIATE datagram frame** (`[len: u16 BE][addr + data]`) — 1. **The ASSOCIATE datagram frame** (`[len: u16 BE][addr + data]`),
the one framed stage (ADR-003). CONNECT's pass-through and BIND's the one framed stage (ADR-003). CONNECT's pass-through and BIND's
pass-through have no framing for BAST to describe. pass-through have no framing for BAST to describe.
What this BAST deliberately does **not** cover: What this BAST deliberately does **not** cover:
- **The RFC 1928 wire rules** — method negotiation, command/reply - **The RFC 1928 wire rules**: method negotiation, command/reply
encoding, the `addr` sub-layout (ATYP + addr + port), the RFC UDP encoding, the `addr` sub-layout (ATYP + addr + port), the RFC UDP
datagram header (RSV/FRAG/ATYP/addr/port, front doors only). The datagram header (RSV/FRAG/ATYP/addr/port, front doors only). The
RFC is normative; the forked codecs implement it. RFC is normative; the forked codecs implement it.
- **The open-op params JSON shape** (`{}`) — UTF-8 JSON on the call - **The open-op params JSON shape** (`{}`): UTF-8 JSON on the call
plane; ADR-001. plane; ADR-001.
- **The channels 8-byte chunk header** — the channels layer's own - **The channels 8-byte chunk header**: the channels layer's own
framing (alknet ADR-071/093); stripped transparently before the framing (alknet ADR-071/093); stripped transparently before the
alksocks data plane sees bytes. Specified upstream, not here. alksocks data plane sees bytes. Specified upstream, not here.
- **The EOF sentinel** — the channels-level zero-length chunk - **The EOF sentinel**: the channels-level zero-length chunk
(upstream); the alksocks codecs never emit it (the F-2 invariant). (upstream); the alksocks codecs never emit it (the F-2 invariant).
## The BAST document ## The BAST document
@@ -81,13 +81,13 @@ What this BAST deliberately does **not** cover:
} }
``` ```
(Note: `addr + data` together are exactly `len` bytes — the two (Note: `addr + data` together are exactly `len` bytes; the two
`encoding: length-prefixed` fields are the *decoder's* internal `encoding: length-prefixed` fields are the *decoder's* internal
split of one frame's payload; on the wire `len` covers both, and split of one frame's payload; on the wire `len` covers both, and
`data`'s length is `len − len(addr)`, decodable because the ATYP `data`'s length is `len − len(addr)`, decodable because the ATYP
determines the address length. The two-field split documents the determines the address length. The two-field split documents the
decoder's contract, not extra wire bytes. **Joint constraint:** a decoder's contract, not extra wire bytes. **Joint constraint:** a
frame is legal only when `len(addr) + len(data) ≤ 65535` — the frame is legal only when `len(addr) + len(data) ≤ 65535`; the
per-field maxima are not independent (a 65507-byte datagram to a per-field maxima are not independent (a 65507-byte datagram to a
255-char domain would need 65766 frame bytes and must be rejected 255-char domain would need 65766 frame bytes and must be rejected
`Oversize` at frame time; wire.md §The Data Plane carries the `Oversize` at frame time; wire.md §The Data Plane carries the
@@ -95,7 +95,7 @@ arithmetic).)
## Annotations ## Annotations
### `AssociateFrame` — binary, exact ### `AssociateFrame`: binary, exact
Maps 1:1 to the on-wire bytes: `len` as `uint16` big-endian (2 Maps 1:1 to the on-wire bytes: `len` as `uint16` big-endian (2
bytes), then exactly `len` bytes = `addr + data`. The `addr` bytes), then exactly `len` bytes = `addr + data`. The `addr`
@@ -109,10 +109,10 @@ One frame per datagram, in both directions of the channel
`BiStream`, for the whole datagram stage (association lifetime = `BiStream`, for the whole datagram stage (association lifetime =
channel lifetime). Frames are self-delimiting on the byte stream; a channel lifetime). Frames are self-delimiting on the byte stream; a
decoder is an incremental state machine across chunk boundaries decoder is an incremental state machine across chunk boundaries
(boundary preservation is mandatory — an empty datagram is a legal (boundary preservation is mandatory; an empty datagram is a legal
zero-length payload, never EOF; alktunnels F-2's invariant, met zero-length payload, never EOF; alktunnels F-2's invariant, met
structurally and stated at payload level: `len == 0` — zero inner structurally and stated at payload level: `len == 0`, zero inner
bytes — is unreachable from a compliant peer, since `addr` is always bytes, is unreachable from a compliant peer, since `addr` is always
≥ 7 bytes). ≥ 7 bytes).
### Layering note ### Layering note
@@ -120,7 +120,7 @@ bytes — is unreachable from a compliant peer, since `addr` is always
The frame codec sits in the wire layer, applied **exactly once**: The frame codec sits in the wire layer, applied **exactly once**:
`Egress::next_reply` returns *inner* bytes (`addr + data`); the `Egress::next_reply` returns *inner* bytes (`addr + data`); the
producer's frame writer adds the single length prefix (ADR-003's producer's frame writer adds the single length prefix (ADR-003's
framing-once invariant — the double-framing bug class POC #2 framing-once invariant; the double-framing bug class POC #2
caught). The consumer side mirrors it: the session's send frames caught). The consumer side mirrors it: the session's send frames
once, its reader state persists across receives. The BAST describes once, its reader state persists across receives. The BAST describes
the wire inside the `BiStream` only; `pump_bidi` (ADR-050) is the wire inside the `BiStream` only; `pump_bidi` (ADR-050) is
@@ -130,10 +130,10 @@ upstream's, not re-specified here.
- [ADR-003](decisions/003-udp-handle-swap.md) (the handle swap + the - [ADR-003](decisions/003-udp-handle-swap.md) (the handle swap + the
frame), [ADR-001](decisions/001-alpn-and-open-op.md) (the JSON frame), [ADR-001](decisions/001-alpn-and-open-op.md) (the JSON
open-op shape — outside BAST scope), open-op shape, outside BAST scope),
[ADR-011](decisions/011-inline-vendor.md) (the forked codecs) [ADR-011](decisions/011-inline-vendor.md) (the forked codecs)
- [wire.md](wire.md) (the normative prose; §The Data Plane) - [wire.md](wire.md) (the normative prose; §The Data Plane)
- alktunnels `bast.md` (the BAST precedent — same meta-schema, same - alktunnels `bast.md` (the BAST precedent: same meta-schema, same
"binary framing only" scope rule; the length codec's original "binary framing only" scope rule; the length codec's original
document) and alktunnels ADR-003 (the codec alksocks reuses) document) and alktunnels ADR-003 (the codec alksocks reuses)
- POC #2 `poc-udp-associate-findings.md` (the frame's validation - POC #2 `poc-udp-associate-findings.md` (the frame's validation
+31 -31
View File
@@ -1,23 +1,23 @@
--- ---
status: draft status: draft
last_updated: 2026-09-25 (the cred-bearing door's call-connection last_updated: 2026-09-25 (the cred-bearing door's call-connection
contract reconciled — TokenClient's door-only posture, one command per contract reconciled: TokenClient's door-only posture, one command per
accepted connection; the BIND spent posture reconciled — the accepted connection; the BIND spent posture reconciled: the
take-guarded reply#2 reader, review 002 C-1/D-1; the landed session take-guarded reply#2 reader, review 002 C-1/D-1; the landed session
signatures reconciled 2026-09-24; Phase 1 initial draft 2026-09-20) signatures reconciled 2026-09-24; Phase 1 initial draft 2026-09-20)
--- ---
# alksocks — The Consumer Half # alksocks: The Consumer Half
The consumer opens SOCKS5 channels against a produced service and The consumer opens SOCKS5 channels against a produced service and
speaks RFC 1928 inside them. Two consumption shapes, structurally speaks RFC 1928 inside them. Two consumption shapes, structurally
separate (the Phase 0 scope resolution): separate (the Phase 0 scope resolution):
1. **The channels-native session** — a typed client that opens 1. **The channels-native session**: a typed client that opens
`channels/socks5/sub` and drives the RFC conversation in-stream `channels/socks5/sub` and drives the RFC conversation in-stream
(wrapper-aware; the POC #1 `use_stream` / POC #2 associate (wrapper-aware; the POC #1 `use_stream` / POC #2 associate
session shapes). session shapes).
2. **The front door** (optional, feature `local`) — a real local 2. **The front door** (optional, feature `local`): a real local
endpoint (TCP for CONNECT/BIND; UDP for ASSOCIATE) that vanilla endpoint (TCP for CONNECT/BIND; UDP for ASSOCIATE) that vanilla
clients point at; the door bridges each accepted clients point at; the door bridges each accepted
connection/socket onto a channel, with the RFC codec living only connection/socket onto a channel, with the RFC codec living only
@@ -33,35 +33,35 @@ Opens `channels/socks5/sub` via `ChannelClient::open_channel`
(alkcall ADR-037/043), then drives the client side of the RFC (alkcall ADR-037/043), then drives the client side of the RFC
conversation in-stream over the channel's `BiStream`: conversation in-stream over the channel's `BiStream`:
- the forked client typestate's `use_stream` path — generic over - the forked client typestate's `use_stream` path, generic over
the backing stream (POC #1: `Socks5Stream` impls the backing stream (POC #1: `Socks5Stream` impls
`AsyncRead + AsyncWrite` itself, so the session over a `BiStream` `AsyncRead + AsyncWrite` itself, so the session over a `BiStream`
is the intended use, not a hack); is the intended use, not a hack);
- target construction via `ToTargetAddr` (IP-string forms parse to - target construction via `ToTargetAddr` (IP-string forms parse to
IP targets; domain form passes through unresolved — ADR-012); IP targets; domain form passes through unresolved, ADR-012);
- after the reply, the session is a raw byte pipe — the consumer - after the reply, the session is a raw byte pipe: the consumer
reads/writes payload bytes directly (0 B framing, CONNECT). reads/writes payload bytes directly (0 B framing, CONNECT).
### The associate session ### The associate session
The UDP ASSOCIATE client (POC #2's shape): The UDP ASSOCIATE client (POC #2's shape):
- opens the channel, negotiates, sends ASSOCIATE, reads the reply — - opens the channel, negotiates, sends ASSOCIATE, reads the reply;
**receives the sentinel `BND.ADDR` and never uses it for **receives the sentinel `BND.ADDR` and never uses it for
addressing** (the handle is the channel; ADR-003); addressing** (the handle is the channel; ADR-003);
- `send_datagram(dst, bytes)` frames once - `send_datagram(dst, bytes)` frames once
(`[len: u16 BE][addr + data]`, the RFC 1928 address encoding) and (`[len: u16 BE][addr + data]`, the RFC 1928 address encoding) and
writes the frame; writes the frame;
- `recv_datagram()` returns `Some((source_addr, bytes))` per - `recv_datagram()` returns `Some((source_addr, bytes))` per
datagram — its incremental reader state (mid-frame position + datagram; its incremental reader state (mid-frame position +
batched-datagram pending queue) **persists across calls** (a batched-datagram pending queue) **persists across calls** (a
datagram split across channel chunks survives a caller-driven datagram split across channel chunks survives a caller-driven
receive loop; POC #2's finding); receive loop; POC #2's finding);
- `shutdown()` half-closes the stream — the mux's implicit-EOF - `shutdown()` half-closes the stream; the mux's implicit-EOF
sentinel reaches the producer's stage as EOF and the association sentinel reaches the producer's stage as EOF and the association
ends (association lifetime = channel lifetime); ends (association lifetime = channel lifetime);
- **teardown ownership**: the session owns the channel; on Drop it - **teardown ownership**: the session owns the channel; on Drop it
closes/joins — the alktunnels ADR-005 posture (the consumer closes/joins (the alktunnels ADR-005 posture: the consumer
session type owns teardown), combined with ADR-004's session type owns teardown), combined with ADR-004's
ownership-rides-the-handle contract. ownership-rides-the-handle contract.
@@ -72,16 +72,16 @@ client `request()` reads reply#1; reply#2 is hand-read with the
forked public `read_address` codec (reply#1's codec applied again; forked public `read_address` codec (reply#1's codec applied again;
no second codec). The session then rides the pass-through data no second codec). The session then rides the pass-through data
plane. The reply#2 phase is take-guarded: the one normative reader plane. The reply#2 phase is take-guarded: the one normative reader
is `await_inbound` — the first read consumes the session's stream is `await_inbound`, and the first read consumes the session's stream
(the returned plane owns it; the session stays the teardown owner), (the returned plane owns it; the session stays the teardown owner),
so a second call returns the family's spent shape (`Ok(None)`, the so a second call returns the family's spent shape (`Ok(None)`, the
same posture the sibling sessions pin) and cannot re-read the live same posture the sibling sessions pin) and cannot re-read the live
data plane; a failed read restores the inner (the producer ends the data plane; a failed read restores the inner (the producer ends the
session on every post-reply#1 failure exit, so a re-call EOFs — session on every post-reply#1 failure exit, so a re-call EOFs;
never a live data-plane re-read either way). The never a live data-plane re-read either way). The
`reply1()` handle is a view (the advertised BND.ADDR + the raw `reply1()` handle is a view (the advertised BND.ADDR + the raw
pre-reply#2 stream), not a reader. The BIND client is a pre-reply#2 stream), not a reader. The BIND client is a
wrapper-aware-consumer convenience — vanilla clients reach BIND wrapper-aware-consumer convenience; vanilla clients reach BIND
through the front door instead. through the front door instead.
### Error surface ### Error surface
@@ -94,18 +94,18 @@ code surfaced through the session type.
## The Front Doors (feature `local`, optional) ## The Front Doors (feature `local`, optional)
The consumer's optional local exposure — the ssh `-D` shape (POC #1: The consumer's optional local exposure, the ssh `-D` shape (POC #1:
curl end-to-end; POC #2 test 8: the UDP door; POC #5 test 8: the curl end-to-end; POC #2 test 8: the UDP door; POC #5 test 8: the
BIND door): BIND door):
- **The CONNECT/BIND door**: bind a real TCP listener; for each - **The CONNECT/BIND door**: bind a real TCP listener; for each
accepted connection, pipe it raw onto a channel `BiStream` accepted connection, pipe it raw onto a channel `BiStream`
(`copy_bidirectional`, **zero protocol translation** — POC #1's (`copy_bidirectional`, **zero protocol translation**, POC #1's
literal 0 B pass-through conclusion). The door's credential half literal 0 B pass-through conclusion). The door's credential half
(below) is the only RFC work it does. (below) is the only RFC work it does.
- **The UDP door**: bind a real UDP socket; decode RFC-header'd - **The UDP door**: bind a real UDP socket; decode RFC-header'd
datagrams (`parse_udp_request`), send `{addr, data}` frames datagrams (`parse_udp_request`), send `{addr, data}` frames
in-band, return replies RFC-header'd (`new_udp_header`) — the RFC in-band, return replies RFC-header'd (`new_udp_header`): the RFC
codec at the door boundary only (ADR-003). FRAG ≠ 0 → refuse (the codec at the door boundary only (ADR-003). FRAG ≠ 0 → refuse (the
v1 posture). v1 posture).
- **The BIND door's reply#2 fidelity**: the local variant's real - **The BIND door's reply#2 fidelity**: the local variant's real
@@ -118,19 +118,19 @@ BIND door):
translation; for loopback-only or wrapper-aware use. translation; for loopback-only or wrapper-aware use.
- **The cred-bearing door** (RFC 1929): a vanilla client carrying - **The cred-bearing door** (RFC 1929): a vanilla client carrying
`user:pass` gets a real RFC 1929 conversation at the door; the `user:pass` gets a real RFC 1929 conversation at the door; the
door maps the accepted credential to *local* configuration — door maps the accepted credential to *local* configuration (which
which channel identity/endpoint to open the producer channel channel identity/endpoint to open the producer channel with) via
with — via a **static config table** (ADR-005: `user:pass →` a **static config table** (ADR-005: `user:pass →`
channel identity/endpoint selection). The SOCKS password never channel identity/endpoint selection). The SOCKS password never
crosses the channel as a SOCKS password; the channel identity crosses the channel as a SOCKS password; the channel identity
never reaches the vanilla client. The door checks credentials never reaches the vanilla client. The door checks credentials
itself (no forked `AuthMethod` impl needed — the door owns the itself (no forked `AuthMethod` impl needed; the door owns the
accept loop). The mapped identity rides the open-op call's accept loop). The mapped identity rides the open-op call's
`auth_token` payload (CF-005 precedence (1)) via `auth_token` payload (CF-005 precedence (1)) via
`TokenClient::wrap` — the client's channel-0 `CallConnection` is `TokenClient::wrap`; the client's channel-0 `CallConnection` is
taken once per door (the door-only contract: a cred-bearing taken once per door (the door-only contract: a cred-bearing
door's client is a door client; other shapes — the UDP door's door's client is a door client; other shapes, such as the UDP door's
association, channels-native sessions — ride their own association and channels-native sessions, ride their own
connections to the same producer registration, one registration, connections to the same producer registration, one registration,
many connections). The vanilla conversation is many connections). The vanilla conversation is
**one command per accepted connection** (the door's RFC half **one command per accepted connection** (the door's RFC half
@@ -159,10 +159,10 @@ door.serve(channel_client).await?;
``` ```
The landed session signatures: the constructors take `&ChannelClient` The landed session signatures: the constructors take `&ChannelClient`
(shared — many sessions ride one client, the concurrency posture); (shared; many sessions ride one client, the concurrency posture);
`send_datagram` takes `impl ToTargetAddr` (the CONNECT session's `send_datagram` takes `impl ToTargetAddr` (the CONNECT session's
target-construction posture, ADR-012); `recv_datagram()` returns target-construction posture, ADR-012); `recv_datagram()` returns
`Result<Option<(TargetAddr, Bytes)>, SocksSessionError>` — `None` is `Result<Option<(TargetAddr, Bytes)>, SocksSessionError>`; `None` is
the channel EOF (the association's clean end), never an empty the channel EOF (the association's clean end), never an empty
datagram (F-2: an empty payload is a legal datagram). datagram (F-2: an empty payload is a legal datagram).
@@ -185,12 +185,12 @@ None currently blocking this document.
## References ## References
- POC #1 (`poc-connect-wasm-findings.md` — the raw-pipe door, the - POC #1 (`poc-connect-wasm-findings.md`: the raw-pipe door, the
`use_stream` fit), POC #2 (`poc-udp-associate-findings.md` — the `use_stream` fit), POC #2 (`poc-udp-associate-findings.md`: the
associate session, the reader-state finding, the UDP door), POC #5 associate session, the reader-state finding, the UDP door), POC #5
(`poc-bind-findings.md` — the reply#2 reader, the BIND door) (`poc-bind-findings.md`: the reply#2 reader, the BIND door)
- Upstream: alkcall ADR-037/043 (channel open), **alktunnels - Upstream: alkcall ADR-037/043 (channel open), **alktunnels
ADR-005** (consumer session owns teardown — the posture ADR-005** (consumer session owns teardown; the posture
inherited), the CF-005/CF-006 seam inherited), the CF-005/CF-006 seam
- [producer.md](producer.md) (the other half), - [producer.md](producer.md) (the other half),
[wire.md](wire.md) (the normative wire) [wire.md](wire.md) (the normative wire)
@@ -9,10 +9,10 @@ Accepted (2026-09-20, Phase 1)
The crate rides alkcall channels: a produced SOCKS5 resource is an The crate rides alkcall channels: a produced SOCKS5 resource is an
openable ALPN-op, opened by consumers via `ChannelClient` (alkcall openable ALPN-op, opened by consumers via `ChannelClient` (alkcall
ADR-037/043). The ALPN string is wire-stable once published ADR-037/043). The ALPN string is wire-stable once published
(AGENTS.md conventions 5/16 — decide via ADR before the first (AGENTS.md conventions 5/16: decide via ADR before the first
consumer). Phase 0's POCs (#1, #2, #5) all ran one ALPN consumer). Phase 0's POCs (#1, #2, #5) all ran one ALPN
(`alk/socks5`), one open op (`channels/socks5/sub`), with trivially (`alk/socks5`), one open op (`channels/socks5/sub`), with trivially
empty params — the natural reading of "the RFC conversation is the empty params, the natural reading of "the RFC conversation is the
data plane": the SOCKS5 target is unknown at open time (it arrives data plane": the SOCKS5 target is unknown at open time (it arrives
in-stream post-negotiation), so there is nothing for params to carry in-stream post-negotiation), so there is nothing for params to carry
(`poc-connect-wasm-findings.md` §Wire/API notes). (`poc-connect-wasm-findings.md` §Wire/API notes).
@@ -20,26 +20,26 @@ in-stream post-negotiation), so there is nothing for params to carry
## Decision ## Decision
- **ALPN: `alk/socks5`** (provisional → stable on first publish; - **ALPN: `alk/socks5`** (provisional → stable on first publish;
the alk/ prefix per the alkcall ADR-004 ALPN-string convention — the alk/ prefix per the alkcall ADR-004 ALPN-string convention;
AGENTS.md convention 16's "ADR-004/ADR-006" shorthand cites the AGENTS.md convention 16's "ADR-004/ADR-006" shorthand cites the
naming family, of which ADR-004 is the operative one). One ALPN naming family, of which ADR-004 is the operative one). One ALPN
per protocol. per protocol.
- **One open op: `channels/socks5/sub`** — the SOCKS5 session op, - **One open op: `channels/socks5/sub`**: the SOCKS5 session op,
flavor-form like alktunnels' (`…/sub`; alkcall ADR-047: openable flavor-form like alktunnels' (`…/sub`; alkcall ADR-047: openable
ALPNs are operations). All three RFC 1928 commands (CONNECT, ALPNs are operations). All three RFC 1928 commands (CONNECT,
BIND, UDP ASSOCIATE) ride this one op, selected in-stream at BIND, UDP ASSOCIATE) ride this one op, selected in-stream at
command dispatch — the command is an RFC-1928 wire fact, not a command dispatch (the command is an RFC-1928 wire fact, not a
wire-format choice of ours. wire-format choice of ours).
- **Params: the trivial object** `{}` — schema `{type: object}`, - **Params: the trivial object** `{}` (schema `{type: object}`,
additionalProperties false. Nothing else is needed at open time additionalProperties false). Nothing else is needed at open time
(the establisher is a session gate, not a dial — the target is (the establisher is a session gate, not a dial; the target is
unknown). Extensibility: if a later need surfaces per-session unknown). Extensibility: if a later need surfaces per-session
options (e.g. a requested local bind hint), that is an options (e.g. a requested local bind hint), that is an
**additive** params schema change while no params were ever **additive** params schema change while no params were ever
required; consumers that send `{}` stay valid. Params are a required; consumers that send `{}` stay valid. Params are a
self-contained JSON object identifying the produced resource self-contained JSON object identifying the produced resource
(alkcall ADR-039's shape, following alktunnels ADR-001's template). (alkcall ADR-039's shape, following alktunnels ADR-001's template).
- **Reply: `{"channel_id": <u32>}`** — the Sub-typed operation - **Reply: `{"channel_id": <u32>}`**: the Sub-typed operation
reply, as with alktunnels. No extra reply fields in v1 (the reply, as with alktunnels. No extra reply fields in v1 (the
establishment phase is a session gate and has nothing to project; establishment phase is a session gate and has nothing to project;
the alkcall 0.8.0 `with_reply_field` seam remains available the alkcall 0.8.0 `with_reply_field` seam remains available
@@ -49,12 +49,12 @@ in-stream post-negotiation), so there is nothing for params to carry
- The wire surface is minimal and stable: one ALPN + one op + empty - The wire surface is minimal and stable: one ALPN + one op + empty
params + one reply field. The one-way-door surface is the ALPN params + one reply field. The one-way-door surface is the ALPN
string, the op id, and the (trivial) params shape — all frozen at string, the op id, and the (trivial) params shape, all frozen at
first publish. first publish.
- Command-level differentiation (CONNECT vs ASSOCIATE vs BIND) - Command-level differentiation (CONNECT vs ASSOCIATE vs BIND)
lives in the RFC 1928 stream and in the ACL scope check at lives in the RFC 1928 stream and in the ACL scope check at
command dispatch (see ADR-006), not in the op set. No command dispatch (see ADR-006), not in the op set. No
per-command ops — that would re-encode the RFC's own command byte per-command ops: that would re-encode the RFC's own command byte
in our wire format for no benefit. in our wire format for no benefit.
- POC-validated: all three POCs served CONNECT/ASSOCIATE/BIND on - POC-validated: all three POCs served CONNECT/ASSOCIATE/BIND on
the same registration (`connect_still_served_on_the_same_ the same registration (`connect_still_served_on_the_same_
@@ -62,7 +62,7 @@ in-stream post-negotiation), so there is nothing for params to carry
## References ## References
- OQ-SK-01 (params context), OQ-SK-03 (the handle swap — why no - OQ-SK-01 (params context), OQ-SK-03 (the handle swap, why no
correlation params), OQ-SK-08 (BIND rides the same op) correlation params), OQ-SK-08 (BIND rides the same op)
- POC #1 `poc-connect-wasm-findings.md` (the trivial-params finding), - POC #1 `poc-connect-wasm-findings.md` (the trivial-params finding),
POC #2 `poc-udp-associate-findings.md` (one-op regression guard), POC #2 `poc-udp-associate-findings.md` (one-op regression guard),
@@ -1,9 +1,9 @@
# ADR-002: Protocol Base — the Vendored fast-socks5 Typestate Subset # ADR-002: Protocol Base: the Vendored fast-socks5 Typestate Subset
## Status ## Status
Accepted (2026-09-20, Phase 1; the extracted-and-forked posture is Accepted (2026-09-20, Phase 1; the extracted-and-forked posture is
OQ-SK-04's resolution — the extraction mechanics in OQ-SK-04's resolution: the extraction mechanics in
[ADR-011](011-inline-vendor.md), the ownership posture in [ADR-011](011-inline-vendor.md), the ownership posture in
[ADR-013](013-targeted-fork.md)) [ADR-013](013-targeted-fork.md))
@@ -13,7 +13,7 @@ Phase 0 evaluated the protocol-base candidates: fast-socks5 (the
full-fidelity RFC 1928 implementation, explicit typestate server API, full-fidelity RFC 1928 implementation, explicit typestate server API,
generic over `T`), iroh-socks5 (structurally the closest sibling, but generic over `T`), iroh-socks5 (structurally the closest sibling, but
iroh-coupled and an RFC subset), and a hand-rolled codec (rejected as iroh-coupled and an RFC subset), and a hand-rolled codec (rejected as
anti-prior-art — alknet's client hand-rolls and it is the known anti-prior-art; alknet's client hand-rolls, and it is the known
weakness). POC #1 confirmed the load-bearing property: fast-socks5's weakness). POC #1 confirmed the load-bearing property: fast-socks5's
`Socks5ServerProtocol<T, states::*>` accepts a channels `BiStream` `Socks5ServerProtocol<T, states::*>` accepts a channels `BiStream`
directly (`T: AsyncRead + AsyncWrite + Unpin`), and POC #4 measured directly (`T: AsyncRead + AsyncWrite + Unpin`), and POC #4 measured
@@ -24,13 +24,13 @@ functionally complete (a full RFC 1928 CONNECT conversation passes
## Decision ## Decision
- **The protocol layer is built on fast-socks5's explicit typestate - **The protocol layer is built on fast-socks5's explicit typestate
API** — `Socks5ServerProtocol<T, …>` (negotiate/finish_auth/ API**: `Socks5ServerProtocol<T, …>` (negotiate/finish_auth/
read_command/reply_success/reply_error), the client side read_command/reply_success/reply_error), the client side
`Socks5Stream<S>::use_stream` / the associate session, and the `Socks5Stream<S>::use_stream` / the associate session, and the
pure codecs (`new_udp_header`/`parse_udp_request`). pure codecs (`new_udp_header`/`parse_udp_request`).
- **Only the explicit API is used.** The legacy - **Only the explicit API is used.** The legacy
`Socks5Server`/`Socks5Socket`/`Incoming` bind-based API is `Socks5Server`/`Socks5Socket`/`Incoming` bind-based API is
deprecated upstream and is out — never forked into the default deprecated upstream and is out; never forked into the default
path. path.
- **Base = v1.0.0, extracted in-tree and forked** (ADR-011 for the - **Base = v1.0.0, extracted in-tree and forked** (ADR-011 for the
extraction mechanics and the two carried changes; ADR-013 for the extraction mechanics and the two carried changes; ADR-013 for the
@@ -38,7 +38,7 @@ functionally complete (a full RFC 1928 CONNECT conversation passes
`/workspace/fast-socks5` remains the read-only reference for `/workspace/fast-socks5` remains the read-only reference for
differential testing of RFC edge cases; the fork point is noted differential testing of RFC edge cases; the fork point is noted
in the module docs and the bundled license notice. in the module docs and the bundled license notice.
- **Refusals map faithfully per RFC 1928** — `ReplyError` codes - **Refusals map faithfully per RFC 1928**: `ReplyError` codes
(`as_u8`/`from_u8`) are the error vocabulary at the command plane; (`as_u8`/`from_u8`) are the error vocabulary at the command plane;
refusals are never collapsed into generic errors (AGENTS.md refusals are never collapsed into generic errors (AGENTS.md
convention 2). POC #1 validated the mapping (dial refusal → 0x05). convention 2). POC #1 validated the mapping (dial refusal → 0x05).
@@ -46,11 +46,11 @@ functionally complete (a full RFC 1928 CONNECT conversation passes
## Consequences ## Consequences
- The crate owns its protocol code in-tree (the fork), so BIND and - The crate owns its protocol code in-tree (the fork), so BIND and
the `new_reply` publication ride the same tree — no upstream waits. the `new_reply` publication ride the same tree; no upstream waits.
- The typestate's interception points are the layering seams: - The typestate's interception points are the layering seams:
command-read (the dial/accept dispatch), `transfer` (the raw command-read (the dial/accept dispatch), `transfer` (the raw
two-pump), and — where the default handlers are unusable (UDP two-pump), and, where the default handlers are unusable (UDP
ASSOCIATE's unconditional bind) — direct typestate driving. The ASSOCIATE's unconditional bind), direct typestate driving. The
interception-point map is spec'd in [producer.md](../producer.md). interception-point map is spec'd in [producer.md](../producer.md).
- iroh-socks5's design ideas (the `{addr, data}` frame, control-EOF - iroh-socks5's design ideas (the `{addr, data}` frame, control-EOF
association lifetime, FRAG≠0 refusal) were adopted at the design association lifetime, FRAG≠0 refusal) were adopted at the design
@@ -1,4 +1,4 @@
# ADR-003: The Handle Swap — one Channel per Association, Frames In-Band # ADR-003: The Handle Swap: one Channel per Association, Frames In-Band
## Status ## Status
@@ -9,12 +9,12 @@ validated end to end 2026-09-14)
UDP ASSOCIATE is the hard case for the no-bind requirement: RFC 1928 UDP ASSOCIATE is the hard case for the no-bind requirement: RFC 1928
replies with a UDP relay address (`BND.ADDR`/`BND.PORT`) and the replies with a UDP relay address (`BND.ADDR`/`BND.PORT`) and the
client sends datagrams to it — which seems to demand a producer-side client sends datagrams to it, which seems to demand a producer-side
kernel bind, the one thing the protocol layer must never do. kernel bind, the one thing the protocol layer must never do.
The design's load-bearing reading of RFC 1928 (OQ-SK-03, settled The design's load-bearing reading of RFC 1928 (OQ-SK-03, settled
2026-09-13): the ASSOCIATE reply's `BND.ADDR`/`BND.PORT` is *just the 2026-09-13): the ASSOCIATE reply's `BND.ADDR`/`BND.PORT` is *just the
handle for the relay*, and one relay serves the whole association — handle for the relay*, and one relay serves the whole association:
the per-datagram destination is carried in the datagram header the per-datagram destination is carried in the datagram header
(ATYP/addr/port), not in the handle. The relay port was never "a port (ATYP/addr/port), not in the handle. The relay port was never "a port
that goes to one place"; it is a multiplexing endpoint. Swapping the that goes to one place"; it is a multiplexing endpoint. Swapping the
@@ -26,27 +26,27 @@ including the front door.
The in-band (channels-native) UDP ASSOCIATE shape is: The in-band (channels-native) UDP ASSOCIATE shape is:
- **One channel per association** — the CONNECT shape; no second - **One channel per association**: the CONNECT shape; no second
channel, no correlation params (the op's params stay trivial, channel, no correlation params (the op's params stay trivial,
ADR-001). ADR-001).
- **Per-datagram destinations ride in-band frames:** every datagram - **Per-datagram destinations ride in-band frames:** every datagram
is `[len: u16 BE][addr + data]` — the boundary-preserving is `[len: u16 BE][addr + data]`, the boundary-preserving
length codec shaped on alktunnels' (`frame_datagram`/ length codec shaped on alktunnels' (`frame_datagram`/
`DatagramReader`, re-implemented in first-party `src/wire.rs`; `DatagramReader`, re-implemented in first-party `src/wire.rs`;
behavior pinned by the same F-2 tests) behavior pinned by the same F-2 tests)
over the RFC address encoding (`TargetAddr::to_be_bytes()` — the over the RFC address encoding (`TargetAddr::to_be_bytes()`, the
RFC UDP header minus RSV/FRAG). The RFC header codec RFC UDP header minus RSV/FRAG). The RFC header codec
(`new_udp_header`/`parse_udp_request`) exists only at vanilla (`new_udp_header`/`parse_udp_request`) exists only at vanilla
front-door boundaries. RSV/FRAG do not ride the tunnel wire — front-door boundaries. RSV/FRAG do not ride the tunnel wire;
fragmentation is refused at front doors (FRAG ≠ 0 → refuse, the fragmentation is refused at front doors (FRAG ≠ 0 → refuse, the
iroh-socks5 v1 posture; it cannot even be expressed in-band). iroh-socks5 v1 posture; it cannot even be expressed in-band).
- **Association lifetime = channel lifetime** — channel EOF is the - **Association lifetime = channel lifetime**: channel EOF is the
end-of-life signal; no extra control vocabulary (POC #2 confirmed end-of-life signal; no extra control vocabulary (POC #2 confirmed
post-reply garbage is refused structurally: the frame's first byte post-reply garbage is refused structurally: the frame's first byte
is ATYP, so any post-reply control byte parses as a garbage ATYP is ATYP, so any post-reply control byte parses as a garbage ATYP
and fails the association loud). and fails the association loud).
- **The reply's `BND.ADDR`/`BND.PORT` on the in-band path are pure - **The reply's `BND.ADDR`/`BND.PORT` on the in-band path are pure
RFC-compatibility surface** — the sentinel `127.0.0.1:0` (POC #2's RFC-compatibility surface** (the sentinel `127.0.0.1:0`, POC #2's
shape). Only a front door with a real socket reports a real shape). Only a front door with a real socket reports a real
address. address.
- **Producer egress is the `EgressFactory` seam** (ADR-004, the - **Producer egress is the `EgressFactory` seam** (ADR-004, the
@@ -55,7 +55,7 @@ The in-band (channels-native) UDP ASSOCIATE shape is:
via the alktunnels direct op) are policy, not protocol. via the alktunnels direct op) are policy, not protocol.
- **The datagram stage is `select!`-driven** with the egress - **The datagram stage is `select!`-driven** with the egress
readiness contract (next_reply is `Pending` when nothing is readiness contract (next_reply is `Pending` when nothing is
ready — an instantly-ready `Ok(None)` starves a biased select into ready; an instantly-ready `Ok(None)` starves a biased select into
livelock; POC #2's readiness findings are load-bearing API design livelock; POC #2's readiness findings are load-bearing API design
for the crate). Framing is applied exactly once, by the wire for the crate). Framing is applied exactly once, by the wire
layer: `Egress::next_reply` returns *inner* bytes; the producer's layer: `Egress::next_reply` returns *inner* bytes; the producer's
@@ -64,7 +64,7 @@ The in-band (channels-native) UDP ASSOCIATE shape is:
reader state persists.** The associate session's reader state persists.** The associate session's
`send_datagram` frames once; its `recv_datagram`'s incremental `send_datagram` frames once; its `recv_datagram`'s incremental
reader state (mid-frame position + batched-datagram pending reader state (mid-frame position + batched-datagram pending
queue) persists across calls — a datagram split across channel queue) persists across calls; a datagram split across channel
chunks survives a caller-driven receive loop (POC #2: the first chunks survives a caller-driven receive loop (POC #2: the first
cut dropped the reader state per call; the 2-byte-prefix chunk cut dropped the reader state per call; the 2-byte-prefix chunk
test exposed it). This is part of the datagram stage's test exposed it). This is part of the datagram stage's
@@ -76,22 +76,22 @@ The in-band (channels-native) UDP ASSOCIATE shape is:
BIND need no socket at all; ASSOCIATE's "bind" is virtualized as BIND need no socket at all; ASSOCIATE's "bind" is virtualized as
the channel (the handle swap). Local binds remain optional the channel (the handle swap). Local binds remain optional
assembly-layer shapes (front doors; the local egress variant). assembly-layer shapes (front doors; the local egress variant).
- The datagram framing is **wire-stable once published** — the - The datagram framing is **wire-stable once published**; the
one-way door is now closed with empirical ground under it (open one-way door is now closed with empirical ground under it (open
cost ~0.8 ms per composed destination, measured; LRU + dedup cost ~0.8 ms per composed destination, measured; LRU + dedup
pinned by test). pinned by test).
- Boundary preservation is mandatory (empty datagram ≠ EOF — - Boundary preservation is mandatory (empty datagram ≠ EOF;
alktunnels F-2's lesson transfers directly; the frame shape meets alktunnels F-2's lesson transfers directly; the frame shape meets
it structurally). The BAST document for the frame is it structurally). The BAST document for the frame is
[bast.md](../bast.md). [bast.md](../bast.md).
## References ## References
- OQ-SK-03 (the full reasoning trail), OQ-SK-09 (fan-out bound — - OQ-SK-03 (the full reasoning trail), OQ-SK-09 (fan-out bound;
see ADR-010) see ADR-010)
- POC #2 `poc-udp-associate-findings.md` (the validation record + - POC #2 `poc-udp-associate-findings.md` (the validation record +
the readiness/framing/ownership findings) the readiness/framing/ownership findings)
- alktunnels ADR-003 (the length codec + F-2), alktunnels - alktunnels ADR-003 (the length codec + F-2), alktunnels
ADR-007 (the direct op — the composed egress vehicle) ADR-007 (the direct op, the composed egress vehicle)
- iroh-socks5 eval (`docs/research/iroh-socks5-eval.md` — the frame - iroh-socks5 eval (`docs/research/iroh-socks5-eval.md`, the frame
design prior art) design prior art)
@@ -1,9 +1,9 @@
# ADR-004: The Dial-Callback Shape — Function, not Trait, at Three Seams # ADR-004: The Dial-Callback Shape: Function, not Trait, at Three Seams
## Status ## Status
Accepted (2026-09-20, Phase 1; shape resolved empirically — POC #1 Accepted (2026-09-20, Phase 1; shape resolved empirically, with
(2026-09-13), POC #2 (2026-09-14), POC #5 (2026-09-15)) POC #1 (2026-09-13), POC #2 (2026-09-14), POC #5 (2026-09-15))
## Context ## Context
@@ -14,23 +14,23 @@ inversion point for the three I/O planes a SOCKS5 server touches:
the CONNECT dial, the ASSOCIATE datagram egress, and the BIND the CONNECT dial, the ASSOCIATE datagram egress, and the BIND
accept. alktunnels resolved the same question for its establisher accept. alktunnels resolved the same question for its establisher
with the no-trait finding (OQ-TN-05 → alktunnels ADR-004): "produce with the no-trait finding (OQ-TN-05 → alktunnels ADR-004): "produce
boxed halves for a resource" is a function, not a trait — two real boxed halves for a resource" is a function, not a trait; two real
implementations existed as callbacks, and the trait added nothing. implementations existed as callbacks, and the trait added nothing.
The POCs extended that finding to all three SOCKS5 seams: The POCs extended that finding to all three SOCKS5 seams:
- **CONNECT**: the dial cannot live in the establisher — the target - **CONNECT**: the dial cannot live in the establisher; the target
is unknown at open time (it arrives in-stream at command read). is unknown at open time (it arrives in-stream at command read).
The injected `DialFn(target) -> TargetHandle {read, write}` The injected `DialFn(target) -> TargetHandle {read, write}`
closure is called at the command-read interception point closure is called at the command-read interception point
(`poc-connect-wasm-findings.md` §Structural findings). Per-target (`poc-connect-wasm-findings.md` §Structural findings). Per-target
refusal is an RFC reply code; session-level refusal stays refusal is an RFC reply code; session-level refusal stays
channels-level. channels-level.
- **ASSOCIATE**: the `EgressFactory` — called once per association; - **ASSOCIATE**: the `EgressFactory`, called once per association;
the datagram stage owns the egress; stage exit drops it the datagram stage owns the egress; stage exit drops it
(`poc-udp-associate-findings.md` §The per-association egress (`poc-udp-associate-findings.md` §The per-association egress
instance). instance).
- **BIND**: the `BindAcceptFactory` — one instance per session; its - **BIND**: the `BindAcceptFactory`, one instance per session; its
future owns the listener/tunnel and is dropped by the quiet future owns the listener/tunnel and is dropped by the quiet
wait's `select!` on the control-stream branch (the client-gone wait's `select!` on the control-stream branch (the client-gone
teardown; `poc-bind-findings.md` §The quiet wait is teardown; `poc-bind-findings.md` §The quiet wait is
@@ -40,20 +40,20 @@ The POCs extended that finding to all three SOCKS5 seams:
- **No dialer/egress/accept trait.** All three seams are - **No dialer/egress/accept trait.** All three seams are
function-shaped callbacks injected at registration/assembly: function-shaped callbacks injected at registration/assembly:
- `DialFn(target) -> TargetHandle` (boxed halves, `+ Sync` — - `DialFn(target) -> TargetHandle` (boxed halves, `+ Sync`,
satisfies the `ChannelPlan` payload bound; the bound binds satisfying the `ChannelPlan` payload bound; the bound binds
handle/plan payloads downstream — the SOCKS5 establisher handle/plan payloads downstream; the SOCKS5 establisher
itself carries no plan, `Establishment::default()`) itself carries no plan, `Establishment::default()`)
- `EgressFactory() -> impl Egress` (per-association instance; - `EgressFactory() -> impl Egress` (per-association instance;
`next_reply` readiness contract: `Pending` when nothing is `next_reply` readiness contract: `Pending` when nothing is
ready) ready)
- `BindAcceptFactory` (per-session; the future owns its resources - `BindAcceptFactory` (per-session; the future owns its resources
end-to-end — the ownership-rides-the-handle lesson) end-to-end, the ownership-rides-the-handle lesson)
- **Ownership rides the handle structurally.** Plan/handle payloads - **Ownership rides the handle structurally.** Plan/handle payloads
own their channel sessions and socket halves inside the payload own their channel sessions and socket halves inside the payload
(the third witness of the teardown-at-birth pattern: POC #2's (the third witness of the teardown-at-birth pattern: POC #2's
egress table, alktunnels ADR-005, POC #5's `BindTargetHandle`). egress table, alktunnels ADR-005, POC #5's `BindTargetHandle`).
The compiler should enforce it — no `_` bindings of owning The compiler should enforce it: no `_` bindings of owning
values. values.
- **The trait re-evaluation threshold is the same as alktunnels - **The trait re-evaluation threshold is the same as alktunnels
ADR-004's**: revisit if three real implementations exist as trait ADR-004's**: revisit if three real implementations exist as trait
@@ -69,7 +69,7 @@ The POCs extended that finding to all three SOCKS5 seams:
the module map in [overview.md](../overview.md)). the module map in [overview.md](../overview.md)).
- The composed providers (alktunnels direct-op egress, bind-first - The composed providers (alktunnels direct-op egress, bind-first
listen accept) are assembly-layer compositions, not crate listen accept) are assembly-layer compositions, not crate
dependencies of the protocol layer — the protocol crate depends dependencies of the protocol layer; the protocol crate depends
on alkcall only (see [overview.md](../overview.md) §Dependencies; on alkcall only (see [overview.md](../overview.md) §Dependencies;
the composed-provider helpers that reference alktunnels types live the composed-provider helpers that reference alktunnels types live
behind the composition feature, ADR-009). behind the composition feature, ADR-009).
@@ -79,7 +79,7 @@ The POCs extended that finding to all three SOCKS5 seams:
## References ## References
- OQ-SK-01 (the dial policy — the A/B/C options; this ADR closes - OQ-SK-01 (the dial policy, the A/B/C options; this ADR closes
the dialer-trait-vs-callback half), OQ-SK-03 (the egress seam), the dialer-trait-vs-callback half), OQ-SK-03 (the egress seam),
OQ-SK-08 (the accept seam) OQ-SK-08 (the accept seam)
- POC #1/#2/#5 findings (the three seams' empirical records) - POC #1/#2/#5 findings (the three seams' empirical records)
@@ -1,23 +1,23 @@
# ADR-005: Auth Mapping — "Both," Split by the Door # ADR-005: Auth Mapping: "Both," Split by the Door
## Status ## Status
Accepted (2026-09-20, Phase 1; the posture settled 2026-09-13 — Accepted (2026-09-20, Phase 1; the posture settled 2026-09-13
OQ-SK-02 — and POC #1 validated the identity seam end to end) (OQ-SK-02), and POC #1 validated the identity seam end to end)
## Context ## Context
Two auth mechanisms are real: alkcall's identity seam (the per-call Two auth mechanisms are real: alkcall's identity seam (the per-call
opener identity arrives on the open-op hooks — alkcall ledger opener identity arrives on the open-op hooks; alkcall ledger
CF-005/CF-006) and RFC 1929's in-band username/password. Phase 0 CF-005/CF-006) and RFC 1929's in-band username/password. Phase 0
resolved the posture: both mechanisms are real, each on its own side resolved the posture: both mechanisms are real, each on its own side
of the door — the consumer's optional front door (where the vanilla of the door: the consumer's optional front door (where the vanilla
world meets the ACL world). A producer-side password would add world meets the ACL world). A producer-side password would add
nothing the identity seam doesn't already do, and the ACL system is nothing the identity seam doesn't already do, and the ACL system is
strictly better than a shared secret. strictly better than a shared secret.
POC #1's wiring finding (the accept-side install hook must call POC #1's wiring finding (the accept-side install hook must call
`channel0_conn.set_identity(resolved)` — the transport identity does `channel0_conn.set_identity(resolved)`; the transport identity does
not flow to channel-0 dispatch automatically) is a producer-harness not flow to channel-0 dispatch automatically) is a producer-harness
requirement worth surfacing in the spec, not a protocol-layer requirement worth surfacing in the spec, not a protocol-layer
concern. concern.
@@ -27,12 +27,12 @@ concern.
- **Producer side: identity only.** The channels-native open is - **Producer side: identity only.** The channels-native open is
authorized by the alkcall ACL/identity seam. The producer's authorized by the alkcall ACL/identity seam. The producer's
in-band RFC conversation runs NoAuth via the typestate's normal in-band RFC conversation runs NoAuth via the typestate's normal
path — `accept_no_auth` (the greeting and method selection still path: `accept_no_auth` (the greeting and method selection still
happen; POC #2/#5's validated shape: `accept_no_auth → happen; POC #2/#5's validated shape: `accept_no_auth →
read_command`). The negotiation is real but the auth decision is read_command`). The negotiation is real but the auth decision is
vacuous in-band because authorization already happened at the vacuous in-band because authorization already happened at the
open gate. **`skip_auth_this_is_not_rfc_compliant` is excluded open gate. **`skip_auth_this_is_not_rfc_compliant` is excluded
from the in-band path** — it wraps a stream already marked from the in-band path**; it wraps a stream already marked
authenticated, skipping the greeting entirely; feeding it a authenticated, skipping the greeting entirely; feeding it a
client that speaks the RFC greeting would misparse the greeting client that speaks the RFC greeting would misparse the greeting
as command bytes. (At most it belongs to a raw-pipe door that as command bytes. (At most it belongs to a raw-pipe door that
@@ -41,26 +41,26 @@ concern.
where vanilla clients need it.** The door owns credential where vanilla clients need it.** The door owns credential
presentation: a vanilla client carrying `user:pass` gets a real presentation: a vanilla client carrying `user:pass` gets a real
RFC 1929 conversation at the door. The door then maps the RFC 1929 conversation at the door. The door then maps the
accepted credential to *local* configuration — which channel accepted credential to *local* configuration (which channel
identity/endpoint to open the producer channel with — and that identity/endpoint to open the producer channel with), and that
identity reaches the producer via the CF-005/CF-006 seam. **The identity reaches the producer via the CF-005/CF-006 seam. **The
SOCKS password never crosses the channel as a SOCKS password; the SOCKS password never crosses the channel as a SOCKS password; the
channel identity never reaches the vanilla client.** channel identity never reaches the vanilla client.**
- **No fast-socks5 `AuthMethod` impl is required.** The door checks - **No fast-socks5 `AuthMethod` impl is required.** The door checks
credentials itself before/around handing the stream to the credentials itself before/around handing the stream to the
typestate — the door is this crate's code; the trait was designed typestate; the door is this crate's code; the trait was designed
for embedders who don't own the accept loop. for embedders who don't own the accept loop.
- **The door's credential→identity mapping shape: static config.** - **The door's credential→identity mapping shape: static config.**
The mapping is a caller-supplied configuration table The mapping is a caller-supplied configuration table
(`username/password → channel identity or endpoint selection`) (`username/password → channel identity or endpoint selection`),
— static, declarative, assembly-layer. A pluggable lookup static, declarative, assembly-layer. A pluggable lookup
(callback) remains possible as an additive extension if a (callback) remains possible as an additive extension if a
deployment needs dynamic resolution; v1 ships the static shape deployment needs dynamic resolution; v1 ships the static shape
because the front door's use cases (tun2proxy's fixed because the front door's use cases (tun2proxy's fixed
`user:pass@host`, curl's `--proxy-user`) are single-credential or `user:pass@host`, curl's `--proxy-user`) are single-credential or
small-table shapes. small-table shapes.
- **The NoAuth door remains valid** for loopback-only or - **The NoAuth door remains valid** for loopback-only or
wrapper-aware use (POC #1's raw-pipe shape — zero protocol wrapper-aware use (POC #1's raw-pipe shape: zero protocol
translation); the cred-bearing door is the same bridge plus the translation); the cred-bearing door is the same bridge plus the
greeting half. Door variant selection is an assembly-layer greeting half. Door variant selection is an assembly-layer
choice. choice.
@@ -70,15 +70,15 @@ concern.
- Vanilla-client compatibility at the front door is real (RFC 1929), - Vanilla-client compatibility at the front door is real (RFC 1929),
without polluting the channels-native path with shared secrets. without polluting the channels-native path with shared secrets.
- The producer-side optional local backend (a front door in the - The producer-side optional local backend (a front door in the
other direction — vanilla clients → a locally-served SOCKS5 other direction: vanilla clients → a locally-served SOCKS5
resource) offers the same door surface: door = vanilla↔ACL resource) offers the same door surface: door = vanilla↔ACL
boundary, same code shape. boundary, same code shape.
- The mapping is not wire-visible: no params, no reply fields, no - The mapping is not wire-visible: no params, no reply fields, no
ALPN changes — the door is entirely assembly-layer. ALPN changes; the door is entirely assembly-layer.
## References ## References
- OQ-SK-02 (the full reasoning: "replace or second-gate" dissolved — - OQ-SK-02 (the full reasoning: "replace or second-gate" dissolved;
the mechanisms occupy different planes) the mechanisms occupy different planes)
- POC #1 `poc-connect-wasm-findings.md` (the identity-seam wiring - POC #1 `poc-connect-wasm-findings.md` (the identity-seam wiring
finding; the raw-pipe front door) finding; the raw-pipe front door)
@@ -1,20 +1,20 @@
# ADR-006: Access-Control Posture — the Open Gate Plus the Command Gate # ADR-006: Access-Control Posture: the Open Gate Plus the Command Gate
## Status ## Status
Accepted (2026-09-20, Phase 1; scope gates POC-validated in shape — Accepted (2026-09-20, Phase 1; scope gates POC-validated in shape:
POC #1 (`socks5:open`), POC #5 (`socks5:bind` at command dispatch); POC #1 (`socks5:open`), POC #5 (`socks5:bind` at command dispatch);
consequence asymmetry pinned by the integration suite, 2026-09-24) consequence asymmetry pinned by the integration suite, 2026-09-24)
## Context ## Context
SOCKS5 is arbitrary-egress by nature — the open gate plus the target SOCKS5 is arbitrary-egress by nature; the open gate plus the target
policy are the security boundary (AGENTS.md convention 12). policy are the security boundary (AGENTS.md convention 12).
alktunnels resolved the sibling posture (its ADR-006): whatever ACL alktunnels resolved the sibling posture (its ADR-006): whatever ACL
governs the resource governs everything reachable through it, plus governs the resource governs everything reachable through it, plus
whatever policy the SOCKS5 implementation itself applies downstream. whatever policy the SOCKS5 implementation itself applies downstream.
For SOCKS5, BIND adds an *inbound* capability — the accept-side twin For SOCKS5, BIND adds an *inbound* capability, the accept-side twin
of arbitrary egress — so the open gate must distinguish it of arbitrary egress, so the open gate must distinguish it
(OQ-SK-08's security note): an identity with proxy egress must not (OQ-SK-08's security note): an identity with proxy egress must not
implicitly get ingress listeners. POC #5 validated exactly this implicitly get ingress listeners. POC #5 validated exactly this
distinction at command dispatch from the per-call opener identity. distinction at command dispatch from the per-call opener identity.
@@ -23,10 +23,10 @@ distinction at command dispatch from the per-call opener identity.
- **The open gate is the boundary.** The open op's - **The open gate is the boundary.** The open op's
`AccessControl.required_scopes` is `["socks5:open"]` `AccessControl.required_scopes` is `["socks5:open"]`
(`SOCKS5_OPEN_SCOPE`) — scope-gated via (`SOCKS5_OPEN_SCOPE`), scope-gated via
`ChannelCore::register_openable` (the registry runs the ACL `ChannelCore::register_openable` (the registry runs the ACL
before the open handler). Scope-string convention: before the open handler). Scope-string convention:
`<alpn-family>:<capability>` (`socks5:open`, `socks5:bind`) — `<alpn-family>:<capability>` (`socks5:open`, `socks5:bind`),
following alktty's `TTY_OPEN_SCOPE` / alktunnels' following alktty's `TTY_OPEN_SCOPE` / alktunnels'
`tunnel:open`/`tunnel:direct` shape. Stable once published. `tunnel:open`/`tunnel:direct` shape. Stable once published.
- **The command gate: BIND requires `socks5:bind` at command - **The command gate: BIND requires `socks5:bind` at command
@@ -38,7 +38,7 @@ distinction at command dispatch from the per-call opener identity.
`ConnectionNotAllowed` (0x02) refusal while CONNECT still serves `ConnectionNotAllowed` (0x02) refusal while CONNECT still serves
the same identity (POC #5 test 4). the same identity (POC #5 test 4).
- **Target policy is the dialer's concern.** No target allowlists - **Target policy is the dialer's concern.** No target allowlists
in the base crate — the dial callback refuses (per-target refusal in the base crate; the dial callback refuses (per-target refusal
is an RFC reply code; ADR-004's seams). The dialer-refuses shape is an RFC reply code; ADR-004's seams). The dialer-refuses shape
avoids a second policy layer (alktunnels OQ-TN-08's resolution avoids a second policy layer (alktunnels OQ-TN-08's resolution
pattern, evidence-backed). Whatever ACL governs the socks5 pattern, evidence-backed). Whatever ACL governs the socks5
@@ -49,23 +49,23 @@ distinction at command dispatch from the per-call opener identity.
by the `tunnel:direct` grant's ACL (POC #2 validated); the dial by the `tunnel:direct` grant's ACL (POC #2 validated); the dial
callback governs per-destination checks. callback governs per-destination checks.
- **Ownership**: optionally consult `OwnershipProvider` for - **Ownership**: optionally consult `OwnershipProvider` for
resource ownership (alknet ADR-050's seam) — a registration-time resource ownership (alknet ADR-050's seam), a registration-time
choice, not a protocol concern. choice, not a protocol concern.
- **Hub relaying**: the hub's terminate-and-re-produce applies per - **Hub relaying**: the hub's terminate-and-re-produce applies per
hop (alkcall ADR-051's `ChannelRelay`/`HubLegTemplate`); the hub's hop (alkcall ADR-051's `ChannelRelay`/`HubLegTemplate`); the hub's
ACL governs the final dial — the hub is the network boundary. No ACL governs the final dial; the hub is the network boundary. No
socks5-specific relay work. socks5-specific relay work.
## Consequences ## Consequences
- Two grants, clean separation: `socks5:open` (egress) and - Two grants, clean separation: `socks5:open` (egress) and
`socks5:bind` (ingress listeners) — never implied by each other `socks5:bind` (ingress listeners), never implied by each other
(the alktunnels ADR-006 Amendment 1 posture: a capability on its (the alktunnels ADR-006 Amendment 1 posture: a capability on its
own scope). The implication asymmetry is pinned by the integration own scope). The implication asymmetry is pinned by the integration
suite (`tests/core-integration.rs`, the identity/scope matrix): suite (`tests/core-integration.rs`, the identity/scope matrix):
`socks5:open` alone serves CONNECT + ASSOCIATE and is refused BIND `socks5:open` alone serves CONNECT + ASSOCIATE and is refused BIND
(0x02) at the command gate; `socks5:bind` alone grants *nothing* (0x02) at the command gate; `socks5:bind` alone grants *nothing*
on the op — the open gate's `required_scopes = ["socks5:open"]` on the op: the open gate's `required_scopes = ["socks5:open"]`
fails closed for every command (the establisher never runs, no fails closed for every command (the establisher never runs, no
phantom channel); both scopes, granted explicitly, serve phantom channel); both scopes, granted explicitly, serve
everything. A BIND-capable deployment grants both. everything. A BIND-capable deployment grants both.
@@ -73,7 +73,7 @@ distinction at command dispatch from the per-call opener identity.
same op (ADR-001): the command gate is the mechanism, and it is same op (ADR-001): the command gate is the mechanism, and it is
POC-validated (POC #5). POC-validated (POC #5).
- The allowlist-hook variant (a declarative per-target policy at - The allowlist-hook variant (a declarative per-target policy at
registration) is *not* shipped in v1 — the dial callback is the registration) is *not* shipped in v1: the dial callback is the
policy point; see OQ-SK-05's residue (OQ-SK2-07 in the tracker) policy point; see OQ-SK-05's residue (OQ-SK2-07 in the tracker)
for the virtual-subnet translation-layer proposal, which is the for the virtual-subnet translation-layer proposal, which is the
same dial-callback shape. same dial-callback shape.
+12 -12
View File
@@ -1,4 +1,4 @@
# ADR-007: BIND in the Base Crate — the Phase Model Extended # ADR-007: BIND in the Base Crate: the Phase Model Extended
## Status ## Status
@@ -15,7 +15,7 @@ asks for a listener on the producer's network; the producer replies
replies #2 with the peer address before the data plane. The use case replies #2 with the peer address before the data plane. The use case
that makes it real (FTP active mode is the canonical vanilla reason) that makes it real (FTP active mode is the canonical vanilla reason)
plus the composed variant's hub story were found in Phase 0 plus the composed variant's hub story were found in Phase 0
(OQ-SK-08), and POC #5 validated the whole shape end to end — both (OQ-SK-08), and POC #5 validated the whole shape end to end: both
accept variants, the scope gate, the failure mapping, the vanilla accept variants, the scope gate, the failure mapping, the vanilla
front door. front door.
@@ -28,8 +28,8 @@ Nothing here is a one-way door.
- **BIND ships in the base crate** (protocol layer), phase-model - **BIND ships in the base crate** (protocol layer), phase-model
style: request → reply#1 carrying `BND.ADDR` → quiet wait (any style: request → reply#1 carrying `BND.ADDR` → quiet wait (any
control byte after reply#1 is protocol garbage; the session ends — control byte after reply#1 is protocol garbage; the session ends,
exactly two exits: the accept, or the control stream's EOF/ with exactly two exits: the accept, or the control stream's EOF/
garbage/error) → exactly one inbound accept → reply#2 carrying the garbage/error) → exactly one inbound accept → reply#2 carrying the
peer address → raw pass-through data plane (zero per-packet peer address → raw pass-through data plane (zero per-packet
headers in-band, same as CONNECT; only the ASSOCIATE datagram headers in-band, same as CONNECT; only the ASSOCIATE datagram
@@ -37,27 +37,27 @@ Nothing here is a one-way door.
- **The accept seam is `BindAcceptFactory`** (ADR-004). Two - **The accept seam is `BindAcceptFactory`** (ADR-004). Two
provider-backed accept variants (plus the wrapper-aware in-band provider-backed accept variants (plus the wrapper-aware in-band
default when neither is wired), all POC #5-validated: default when neither is wired), all POC #5-validated:
1. **Local backend binds for real** (feature-gated) — the faithful 1. **Local backend binds for real** (feature-gated): the faithful
RFC shape: a real network-B address in reply#1 (the listener's RFC shape: a real network-B address in reply#1 (the listener's
`local_addr`), a real peer in reply#2, vanilla front-door `local_addr`), a real peer in reply#2, vanilla front-door
clients work. **This is the default accept variant** for the clients work. **This is the default accept variant** for the
optional local backend. optional local backend.
2. **Composed (alktunnels listen resource)** — the listener lives 2. **Composed (alktunnels listen resource)**: the listener lives
on a bind-first listen resource (`register_tunnel_bindfirst_ on a bind-first listen resource (`register_tunnel_bindfirst_
openable` + `BindFirstInjections`, alktunnels 0.2.0); per- openable` + `BindFirstInjections`, alktunnels 0.2.0); per-
listener ACL for free, hub-relayable. **Vanilla-fidelity with listener ACL for free, hub-relayable. **Vanilla-fidelity with
no stand-ins** since the graduation: the factory no longer no stand-ins** since the graduation: the factory no longer
carries an expected `bind_addr` — reply#1 advertises the real carries an expected `bind_addr`; reply#1 advertises the real
OS-chosen `bound` (the open-op reply's `bound` field, OS-chosen `bound` (the open-op reply's `bound` field,
alkcall 0.8.0 reply projection); reply#2 carries the real peer alkcall 0.8.0 reply projection); reply#2 carries the real peer
(the forwarded op's `peer` param). (the forwarded op's `peer` param).
3. **Wrapper-aware only** (zeros in reply#1, sentinel reply#2) — 3. **Wrapper-aware only** (zeros in reply#1, sentinel reply#2),
retained for completeness as the in-band default when neither retained for completeness as the in-band default when neither
provider is wired: the handle-swap reading applied to BIND provider is wired: the handle-swap reading applied to BIND
(the reply is RFC-compatibility surface; the data plane is the (the reply is RFC-compatibility surface; the data plane is the
channel). channel).
- **The establishment bound covers only the open-op round trip.** - **The establishment bound covers only the open-op round trip.**
The accept wait lives in the teardown-governed pump phase — FTP- The accept wait lives in the teardown-governed pump phase; FTP-
length BIND waits stop resolving as spurious establishment length BIND waits stop resolving as spurious establishment
refusals (the bind-first graduation's stated second win; refusals (the bind-first graduation's stated second win;
`poc-bind-findings.md` §Establishment-bound, superseded in shape). `poc-bind-findings.md` §Establishment-bound, superseded in shape).
@@ -71,7 +71,7 @@ Nothing here is a one-way door.
- The RFC 1928 command surface is complete: CONNECT + BIND + - The RFC 1928 command surface is complete: CONNECT + BIND +
ASSOCIATE, all three on one op (POC #5 test 7 regression-guarded ASSOCIATE, all three on one op (POC #5 test 7 regression-guarded
the other two). the other two).
- The composed accept path composes against alktunnels 0.2.0 — the - The composed accept path composes against alktunnels 0.2.0: the
bind-first establisher + forwarded op (ADR-008) are the substrate; bind-first establisher + forwarded op (ADR-008) are the substrate;
the POC's pinned workaround (reply#1 first, the tunnel open rides the POC's pinned workaround (reply#1 first, the tunnel open rides
the accept future) is retired with the graduation. the accept future) is retired with the graduation.
@@ -1,9 +1,9 @@
# ADR-008: Egress/Accept Provider Defaults — Local Socket and Composed (Direct Op) # ADR-008: Egress/Accept Provider Defaults: Local Socket and Composed (Direct Op)
## Status ## Status
Accepted (2026-09-20, Phase 1; the A/B assembly-layer choice — Accepted (2026-09-20, Phase 1; the A/B assembly-layer choice
OQ-SK-01's residue — with the composed vehicle settled by (OQ-SK-01's residue), with the composed vehicle settled by
alktunnels 0.2.0 ADR-007) alktunnels 0.2.0 ADR-007)
## Context ## Context
@@ -11,12 +11,12 @@ alktunnels 0.2.0 ADR-007)
OQ-SK-01's dialer-trait-vs-callback half is closed (ADR-004); what OQ-SK-01's dialer-trait-vs-callback half is closed (ADR-004); what
remained was the **default provider set**: where does the dial remained was the **default provider set**: where does the dial
callback come from in the base crate's optional shapes? Option A callback come from in the base crate's optional shapes? Option A
(dial via alktunnels — the composed shape), Option B (dial directly (dial via alktunnels, the composed shape), Option B (dial directly,
— local TCP), Option C (injected dialer trait — rejected, ADR-004). local TCP), Option C (injected dialer trait, rejected, ADR-004).
alktunnels 0.2.0 removed Option A's blocker (the direct op alktunnels 0.2.0 removed Option A's blocker (the direct op
`channels/tunnel/direct` is the dynamic-target channel — one open `channels/tunnel/direct` is the dynamic-target channel: one open
per CONNECT target, what it was graduated for), and POC #2 measured per CONNECT target, what it was graduated for), and POC #2 measured
its per-destination open cost (~0.8 ms, loopback) — fine for DNS + its per-destination open cost (~0.8 ms, loopback), fine for DNS +
browsing. browsing.
## Decision ## Decision
@@ -25,7 +25,7 @@ browsing.
- **Local-socket providers** (behind the `local` feature): a real - **Local-socket providers** (behind the `local` feature): a real
`TcpStream` dial (CONNECT), a real `UdpSocket` egress `TcpStream` dial (CONNECT), a real `UdpSocket` egress
(ASSOCIATE, one socket per association, per-datagram (ASSOCIATE, one socket per association, per-datagram
`send_to`/`recv_from` — no flow table; domain destinations `send_to`/`recv_from`; no flow table; domain destinations
resolve at the egress, the ssh `-D` semantic), a real resolve at the egress, the ssh `-D` semantic), a real
`TcpListener` accept (BIND, ADR-007 variant 1). These are the `TcpListener` accept (BIND, ADR-007 variant 1). These are the
faithful-RFC shapes and the simplest assembly story. faithful-RFC shapes and the simplest assembly story.
@@ -33,10 +33,10 @@ browsing.
dial callback opens an alktunnels direct-op channel naming the dial callback opens an alktunnels direct-op channel naming the
target (`open_direct`, `Substrate::Tcp`/`Substrate::Udp`); the target (`open_direct`, `Substrate::Tcp`/`Substrate::Udp`); the
UDP table is a lazy destination→tunnel table (open on first UDP table is a lazy destination→tunnel table (open on first
datagram, LRU-evict at a cap — POC #2's `ComposedEgress` shape). datagram, LRU-evict at a cap, POC #2's `ComposedEgress` shape).
Hub relaying is per-target terminate-and-re-produce Hub relaying is per-target terminate-and-re-produce
(alkcall ADR-051). (alkcall ADR-051).
- **Neither provider is a dependency of the protocol layer** — - **Neither provider is a dependency of the protocol layer**;
both are assembly-layer wiring (ADR-004); the features exist so both are assembly-layer wiring (ADR-004); the features exist so
the providers ship *in the crate* rather than forcing every the providers ship *in the crate* rather than forcing every
deployment to hand-roll them. The feature gates and module deployment to hand-roll them. The feature gates and module
@@ -46,11 +46,11 @@ browsing.
The feature-gated providers are convenience constructors over The feature-gated providers are convenience constructors over
the same seams. A missing callback means that command refuses the same seams. A missing callback means that command refuses
RFC-faithfully (`CommandNotSupported`-class reply), never a RFC-faithfully (`CommandNotSupported`-class reply), never a
panic — the command gate is the open handler's dispatch, and a panic: the command gate is the open handler's dispatch, and a
provider-less command is an unprovided capability, not an error provider-less command is an unprovided capability, not an error
condition. condition.
- **Per-target ACL is the composed variant's benefit** (each - **Per-target ACL is the composed variant's benefit** (each
destination rides its own `tunnel:direct`-governed channel — destination rides its own `tunnel:direct`-governed channel,
ADR-006); the local variant's target policy is the dial ADR-006); the local variant's target policy is the dial
callback itself. callback itself.
@@ -1,11 +1,11 @@
# ADR-009: Feature-Gate Layout — `local` and the Composition Boundary # ADR-009: Feature-Gate Layout: `local` and the Composition Boundary
## Status ## Status
Accepted (2026-09-20, Phase 1; the wasm-clean default-crate invariant Accepted (2026-09-20, Phase 1; the wasm-clean default-crate invariant
is the alk* family posture — AGENTS.md convention 4). Amended is the alk* family posture, AGENTS.md convention 4). Amended
2026-09-21 (pre-decomposition): the vendor's `net` gate dissolves 2026-09-21 (pre-decomposition): the vendor's `net` gate dissolves
under ADR-011's extraction amendment — the vendor is under ADR-011's extraction amendment; the vendor is
wasm-clean by construction; features collapse to `local` + wasm-clean by construction; features collapse to `local` +
`composed`; see §Amendment. `composed`; see §Amendment.
@@ -13,20 +13,20 @@ wasm-clean by construction; features collapse to `local` +
The default crate must compile to `wasm32-unknown-unknown` (the The default crate must compile to `wasm32-unknown-unknown` (the
alktty/alktunnels precedent; the wasm shape is a sandboxed alktty/alktunnels precedent; the wasm shape is a sandboxed
SOCKS5 consumer — pure byte framing, no local sockets needed SOCKS5 consumer (pure byte framing, no local sockets needed
client-side; phase-0 OQ-SK-04's analysis). Two groups of client-side; phase-0 OQ-SK-04's analysis). Two groups of
platform-specific or sibling-dependent code need gating: platform-specific or sibling-dependent code need gating:
1. **The crate's own local backends** (dial/listen/egress providers 1. **The crate's own local backends** (dial/listen/egress providers
touching real sockets — ADR-008's local variants). touching real sockets, ADR-008's local variants).
2. **The composed providers** (the alktunnels-composed egress/accept 2. **The composed providers** (the alktunnels-composed egress/accept
helpers — ADR-008's composed variants), which need the helpers, ADR-008's composed variants), which need the
alktunnels dependency. alktunnels dependency.
The vendor originally contributed a third gate: its kernel-net The vendor originally contributed a third gate: its kernel-net
region was to ride a default-on `net` feature (POC #4's measured region was to ride a default-on `net` feature (POC #4's measured
shape, ~120 cfg lines across 5 files). ADR-011's 2026-09-21 shape, ~120 cfg lines across 5 files). ADR-011's 2026-09-21
amendment pins the vendor as *extraction* — the kernel-net region is amendment pins the vendor as *extraction*; the kernel-net region is
not copied, so that gate no longer exists (see §Amendment). POC #4's not copied, so that gate no longer exists (see §Amendment). POC #4's
gate map survives as the extraction recipe, not as in-tree cfg. gate map survives as the extraction recipe, not as in-tree cfg.
@@ -38,26 +38,26 @@ gate map survives as the extraction recipe, not as in-tree cfg.
feature-gated, never imported by the protocol layer. feature-gated, never imported by the protocol layer.
- **The default build has no `net` feature**: the extracted vendor - **The default build has no `net` feature**: the extracted vendor
contains zero kernel-net code, so the wasm-clean subset is the contains zero kernel-net code, so the wasm-clean subset is the
whole crate — there is nothing to gate (ADR-011 §Amendment). whole crate; there is nothing to gate (ADR-011 §Amendment).
- **`local` (default-off)**: the crate's first-party local backends - **`local` (default-off)**: the crate's first-party local backends,
— the TCP dial, the UDP egress socket, the BIND listener, and the namely the TCP dial, the UDP egress socket, the BIND listener, and
front doors (the consumer's ssh `-D` local exposure; the the front doors (the consumer's ssh `-D` local exposure; the
producer's local backend). Brings `tokio/net` + `socket2` into the producer's local backend). Brings `tokio/net` + `socket2` into the
graph. Non-wasm by design (enabling it on wasm is a build error, graph. Non-wasm by design (enabling it on wasm is a build error,
the alktty posture). the alktty posture).
- **`composed` (default-off)**: the composed providers over - **`composed` (default-off)**: the composed providers over
alktunnels 0.2.0 (direct-op egress, bind-first listen accept) — alktunnels 0.2.0 (direct-op egress, bind-first listen accept),
the only feature that adds the alktunnels dependency. The the only feature that adds the alktunnels dependency. The
protocol layer never references alktunnels types; the composed protocol layer never references alktunnels types; the composed
module does, injected at the assembly layer (ADR-004/ADR-008). module does, injected at the assembly layer (ADR-004/ADR-008).
- **tokio stays `default-features = false`** with the wasm-clean - **tokio stays `default-features = false`** with the wasm-clean
subset (`rt`, `sync`, `io-util`, `macros`, `time`) — **never subset (`rt`, `sync`, `io-util`, `macros`, `time`); **never
`features = ["full"]`** (AGENTS.md convention 4); `net`-shaped `features = ["full"]`** (AGENTS.md convention 4); `net`-shaped
additions ride `local` alone. additions ride `local` alone.
## Amendment (2026-09-21, pre-decomposition — the vendor's `net` gate dissolves) ## Amendment (2026-09-21, pre-decomposition: the vendor's `net` gate dissolves)
The original decision carried a third feature — `net` The original decision carried a third feature, `net`
(default-on), the vendored subset's kernel-net gate, with `local` (default-on), the vendored subset's kernel-net gate, with `local`
implying it. ADR-011's extraction amendment dissolves it: implying it. ADR-011's extraction amendment dissolves it:
@@ -66,12 +66,12 @@ implying it. ADR-011's extraction amendment dissolves it:
`Socks5Server`/`Incoming` API, `Socks5Datagram`'s kernel path, the `Socks5Server`/`Incoming` API, `Socks5Datagram`'s kernel path, the
DNS-resolve helper, the net-only tests), the `socks4` module, and DNS-resolve helper, the net-only tests), the `socks4` module, and
the legacy `Config`/`Authentication` layer are not copied into the the legacy `Config`/`Authentication` layer are not copied into the
tree — the extraction is wasm-clean by construction, with no tree; the extraction is wasm-clean by construction, with no
in-tree cfg gate. in-tree cfg gate.
- Features collapse to two: **`local`** (first-party socket - Features collapse to two: **`local`** (first-party socket
providers + front doors, ADR-008; brings `tokio/net` + `socket2` providers + front doors, ADR-008; brings `tokio/net` + `socket2`
into the graph itself) and **`composed`** (the alktunnels into the graph itself) and **`composed`** (the alktunnels
providers). `local` no longer implies a separate `net` feature — providers). `local` no longer implies a separate `net` feature;
it is the sole gate for socket/platform I/O. it is the sole gate for socket/platform I/O.
- POC #4's gate map survives as the *extraction recipe* (its - POC #4's gate map survives as the *extraction recipe* (its
ungated inventory is what to copy), not as in-tree cfg. ungated inventory is what to copy), not as in-tree cfg.
@@ -81,24 +81,24 @@ implying it. ADR-011's extraction amendment dissolves it:
## Consequences ## Consequences
- Wasm consumers (the sandboxed adapter story — a product input, - Wasm consumers (the sandboxed adapter story; a product input,
nothing waits on it) build the default crate clean; the protocol nothing waits on it) build the default crate clean; the protocol
substance *is* the wasm-relevant surface. substance *is* the wasm-relevant surface.
- The verification commands cover the gates: `cargo test` (default), - The verification commands cover the gates: `cargo test` (default),
`cargo test --all-features`, and both wasm checks `cargo test --all-features`, and both wasm checks
(`cargo check/clippy --target wasm32-unknown-unknown`) — the (`cargo check/clippy --target wasm32-unknown-unknown`), the
structural guard for the invariant. structural guard for the invariant.
- The vendor's carried changes (BIND, `new_reply` publication) live - The vendor's carried changes (BIND, `new_reply` publication) live
in the extracted region where they touch codecs/typestate; nothing in the extracted region where they touch codecs/typestate; nothing
vendored is gated — the only feature-gated socket code is the vendored is gated; the only feature-gated socket code is the
crate's own first-party `local` module. crate's own first-party `local` module.
## References ## References
- OQ-SK-04 (resolved: the extracted-and-forked base — ADR-011/ - OQ-SK-04 (resolved: the extracted-and-forked base; ADR-011/
ADR-013), OQ-SK-01/03/08 ADR-013), OQ-SK-01/03/08
(the providers the features gate) (the providers the features gate)
- POC #4 `poc-connect-wasm-findings.md` (the measured gate — now the - POC #4 `poc-connect-wasm-findings.md` (the measured gate, now the
extraction recipe), extraction recipe),
alktty's `local` feature (the posture precedent), alktunnels alktty's `local` feature (the posture precedent), alktunnels
ADR-004 + overview.md (the same feature split) ADR-004 + overview.md (the same feature split)
@@ -1,4 +1,4 @@
# ADR-010: Channel Capacity — Proxy-Workload Recommendation, Not a Wire Change # ADR-010: Channel Capacity: Proxy-Workload Recommendation, Not a Wire Change
## Status ## Status
@@ -14,12 +14,12 @@ CONNECT channel per flow; composed UDP egress spends a downstream
channel per destination; BIND spends an accept resource per channel per destination; BIND spends an accept resource per
listener). A proxy-shaped deployment will discover the cap in listener). A proxy-shaped deployment will discover the cap in
production unless the crate documents the posture. The caps are production unless the crate documents the posture. The caps are
policy knobs — configurable without a wire change (the `u32` policy knobs, configurable without a wire change (the `u32`
channel-ID wire space is ~4 billion) — so the resolution is config channel-ID wire space is ~4 billion), so the resolution is config
plus documentation, never wire. plus documentation, never wire.
Considered and rejected: in-channel port multiplexing (a Considered and rejected: in-channel port multiplexing (a
`[port: u16][len][payload]` per-chunk prefix) — it re-opens the `[port: u16][len][payload]` per-chunk prefix); it re-opens the
demux-vs-phase trade ADR-003 (the handle swap) resolved, breaks demux-vs-phase trade ADR-003 (the handle swap) resolved, breaks
the 0-B pass-through that lets the forked `transfer` drop in the 0-B pass-through that lets the forked `transfer` drop in
unchanged, imposes chunk boundaries on a stream RFC 1928 treats as unchanged, imposes chunk boundaries on a stream RFC 1928 treats as
@@ -28,14 +28,14 @@ convention (AGENTS.md #10).
## Decision ## Decision
- **No wire change — rejected permanently.** The caps are policy - **No wire change (rejected permanently).** The caps are policy
knobs (alkcall ADR-040/041); the resolution is config. knobs (alkcall ADR-040/041); the resolution is config.
- **Assembly-layer config is the mechanism**: a proxy-shaped - **Assembly-layer config is the mechanism**: a proxy-shaped
deployment raises the per-connection/per-identity caps at deployment raises the per-connection/per-identity caps at
registration; the default stays conservative for everyone else. registration; the default stays conservative for everyone else.
- **Documented proxy-workload recommendation**: the crate docs - **Documented proxy-workload recommendation**: the crate docs
recommend a raised cap for proxy deployments, with the memory recommend a raised cap for proxy deployments, with the memory
arithmetic — a session's memory is bounded + predictable (CONNECT/ arithmetic: a session's memory is bounded + predictable (CONNECT/
ASSOCIATE are bounded-buffer pumps; BIND adds one listener + one ASSOCIATE are bounded-buffer pumps; BIND adds one listener + one
accept), so the per-connection cap can rise safely relative to a accept), so the per-connection cap can rise safely relative to a
general-purpose deployment. A concrete recommended figure is a general-purpose deployment. A concrete recommended figure is a
@@ -44,12 +44,12 @@ convention (AGENTS.md #10).
cap → per-identity cap → dial/accept policy. Relaxing the caps cap → per-identity cap → dial/accept policy. Relaxing the caps
leans on the rest. The per-identity DoS brake is arguably leans on the rest. The per-identity DoS brake is arguably
*stronger* here (each SOCKS5 session maps to real producer-side *stronger* here (each SOCKS5 session maps to real producer-side
resources — a dial, a downstream channel, an accept listener); resources (a dial, a downstream channel, an accept listener);
the per-connection memory bound is arguably *weaker* (the three the per-connection memory bound is arguably *weaker* (the three
command shapes are well-known and bounded). command shapes are well-known and bounded).
- **Revisit trigger** (OQ-SK-09's): if a deployment hits a fixed - **Revisit trigger** (OQ-SK-09's): if a deployment hits a fixed
upstream channel budget that config cannot raise, the additive upstream channel budget that config cannot raise, the additive
escape is a *new multiplexed ALPN* — never a change to this escape is a *new multiplexed ALPN*, never a change to this
ALPN's framing. ALPN's framing.
## Consequences ## Consequences
@@ -57,7 +57,7 @@ convention (AGENTS.md #10).
- Deployments get the guidance before they hit the cap; no protocol - Deployments get the guidance before they hit the cap; no protocol
surface changes. surface changes.
- The rejected multiplexing proposal has a recorded rationale and a - The rejected multiplexing proposal has a recorded rationale and a
revisit trigger — the escape hatch is additive (new ALPN), never revisit trigger; the escape hatch is additive (new ALPN), never
a framing change. a framing change.
## References ## References
@@ -1,12 +1,12 @@
# ADR-011: the Inline Vendor — fast-socks5's `T`-Generic Subset, In-Tree # ADR-011: the Inline Vendor: fast-socks5's `T`-Generic Subset, In-Tree
## Status ## Status
Superseded by [ADR-013](013-targeted-fork.md) (2026-09-22): the Superseded by [ADR-013](013-targeted-fork.md) (2026-09-22): the
maintenance posture (the vendor framing — re-base/re-vendor maintenance posture (the vendor framing, re-base/re-vendor
discipline, the verbatim/lint-allow apparatus) is retired; the discipline, the verbatim/lint-allow apparatus) is retired; the
extracted region is a targeted fork, module `rfc1928`. **The extracted region is a targeted fork, module `rfc1928`. **The
extraction mechanics here remain authoritative** — the cut table, extraction mechanics here remain authoritative**; the cut table,
the two carried changes (BIND, `new_reply`), the §Amendment the two carried changes (BIND, `new_reply`), the §Amendment
(extraction, not wholesale copy; the net gate dissolves) describe (extraction, not wholesale copy; the net gate dissolves) describe
what was copied and what was not; only the post-copy maintenance what was copied and what was not; only the post-copy maintenance
@@ -16,57 +16,57 @@ OQ-SK-04's resolution), Amended 2026-09-21 (pre-decomposition).
## Context ## Context
fast-socks5 is **third-party upstream** (v1.0.0, MIT; fast-socks5 is **third-party upstream** (v1.0.0, MIT;
`/workspace/fast-socks5` is a read-only reference checkout — we do `/workspace/fast-socks5` is a read-only reference checkout; we do
not own it, and it has not been forked). The wrapper's needs go not own it, and it has not been forked). The wrapper's needs go
beyond what it publishes: beyond what it publishes:
1. **The wasm gate** — `tokio::net` + `socket2` (via `mio`) hard-error 1. **The wasm gate**: `tokio::net` + `socket2` (via `mio`) hard-error
on `wasm32-unknown-unknown`; the default crate must compile there on `wasm32-unknown-unknown`; the default crate must compile there
(AGENTS.md convention 4). POC #4 measured the gate: ~120 changed (AGENTS.md convention 4). POC #4 measured the gate: ~120 changed
lines across 5 files, all `#[cfg]` insertions; the wasm-clean lines across 5 files, all `#[cfg]` insertions; the wasm-clean
subset is functionally complete (a full RFC 1928 CONNECT subset is functionally complete (a full RFC 1928 CONNECT
conversation passes `--no-default-features`). conversation passes `--no-default-features`).
2. **BIND is absent** — `Socks5Command::TCPBind` is parsed but every 2. **BIND is absent**: `Socks5Command::TCPBind` is parsed but every
handler refuses it; POC #5 proved the wrapper must drive the handler refuses it; POC #5 proved the wrapper must drive the
typestate itself for the BIND shape (ADR-007). typestate itself for the BIND shape (ADR-007).
3. **The `run_udp_proxy_custom` bind seam** — the "custom" seam is 3. **The `run_udp_proxy_custom` bind seam**: the "custom" seam is
narrower than it looks: it calls `udp_bind_random_port` narrower than it looks: it calls `udp_bind_random_port`
unconditionally and derives the reply port from that kernel unconditionally and derives the reply port from that kernel
socket; the channels path cannot use it (verified 2026-09-13). socket; the channels path cannot use it (verified 2026-09-13).
The channels path drives the typestate directly; making the bind The channels path drives the typestate directly; making the bind
optional / accepting a caller-supplied socket is an in-tree fix. optional / accepting a caller-supplied socket is an in-tree fix.
4. **`new_reply` is private** — the reply encoder is private; the 4. **`new_reply` is private**: the reply encoder is private; the
BIND reply#2 writer otherwise hand-rolls the 10-byte reply BIND reply#2 writer otherwise hand-rolls the 10-byte reply
(POC #5's courtesy item, now a vendor change). (POC #5's courtesy item, now a vendor change).
Two facts settled the posture: the ownership premise was wrong Two facts settled the posture: the ownership premise was wrong
(no cheap "file asks / PR" path), and the carried needs (2–4) exist (no cheap "file asks / PR" path), and the carried needs (2–4) exist
regardless of the wasm question — so the vendor pays for itself regardless of the wasm question, so the vendor pays for itself
without leaning on the adapter story. The change set is exactly the without leaning on the adapter story. The change set is exactly the
shape POC #4 measured as minimal. (Need 1 later dissolves — shape POC #4 measured as minimal. (Need 1 later dissolves,
§Amendment; needs 3's handler is not extracted with it.) §Amendment; needs 3's handler is not extracted with it.)
## Decision ## Decision
- **Extract the `T`-generic subset into this crate** (see §Amendment - **Extract the `T`-generic subset into this crate** (see §Amendment
for the mechanical reading of "extract"): the codecs, the typestate for the mechanical reading of "extract"): the codecs, the typestate
machinery, the client `use_stream` path — the parts already generic machinery, the client `use_stream` path, the parts already generic
over `T: AsyncRead + AsyncWrite + Unpin`. The extraction contains over `T: AsyncRead + AsyncWrite + Unpin`. The extraction contains
zero kernel-net code and is wasm-clean by construction. zero kernel-net code and is wasm-clean by construction.
- **Carry the four needs in the same vendor**: the net gate, BIND - **Carry the four needs in the same vendor**: the net gate, BIND
(the typestate-driven shape, ADR-007), the `run_udp_proxy_custom` (the typestate-driven shape, ADR-007), the `run_udp_proxy_custom`
seam fix, and the `new_reply` publication. (Post-Amendment: the seam fix, and the `new_reply` publication. (Post-Amendment: the
net gate dissolves — see §Amendment; BIND and `new_reply` are the net gate dissolves; see §Amendment; BIND and `new_reply` are the
two that touch extracted code.) two that touch extracted code.)
- **Note the vendor point (v1.0.0) in the vendored code**; keep the - **Note the vendor point (v1.0.0) in the vendored code**; keep the
MIT license obligations (upstream is MIT; preserve the license MIT license obligations (upstream is MIT; preserve the license
header per file or a bundled notice). header per file or a bundled notice).
- **`/workspace/fast-socks5` stays the differential-testing - **`/workspace/fast-socks5` stays the differential-testing
reference** for RFC edge cases (reply-code mapping, domain reference** for RFC edge cases (reply-code mapping, domain
addressing, fragmentation) — keep `/workspace/fast-socks5` for addressing, fragmentation); keep `/workspace/fast-socks5` for
differential testing; never wait on an upstream merge. differential testing; never wait on an upstream merge.
- **Offering the net-gate upstream as a courtesy PR stays optional** - **Offering the net-gate upstream as a courtesy PR stays optional**
(POC #4: it is small enough to offer as-is) — never a wait, never (POC #4: it is small enough to offer as-is), never a wait, never
a dependency. a dependency.
## Consequences ## Consequences
@@ -75,7 +75,7 @@ shape POC #4 measured as minimal. (Need 1 later dissolves —
cadence, no feature waits, and the vendored code co-evolves with cadence, no feature waits, and the vendored code co-evolves with
the wrapper (the carried changes are first-class, not patches). the wrapper (the carried changes are first-class, not patches).
- The cost is maintenance of the extracted subset (the `T`-generic - The cost is maintenance of the extracted subset (the `T`-generic
region + codecs — a fraction of upstream's ~4k LoC; POC #4's region + codecs, a fraction of upstream's ~4k LoC; POC #4's
ungated inventory is the shopping list). The diff vs upstream is ungated inventory is the shopping list). The diff vs upstream is
deliberate and bounded: the extraction cut + the carried changes + deliberate and bounded: the extraction cut + the carried changes +
the license/provenance notes. the license/provenance notes.
@@ -86,7 +86,7 @@ shape POC #4 measured as minimal. (Need 1 later dissolves —
- Differential testing against `/workspace/fast-socks5` guards the - Differential testing against `/workspace/fast-socks5` guards the
RFC edge cases the extracted code must keep passing. RFC edge cases the extracted code must keep passing.
## Amendment (2026-09-21, pre-decomposition — "extract" pinned, the `net` gate dissolves) ## Amendment (2026-09-21, pre-decomposition: "extract" pinned, the `net` gate dissolves)
The decision's wording ("vendor the `T`-generic subset") supported The decision's wording ("vendor the `T`-generic subset") supported
two mechanical readings; this amendment pins the intent so the two mechanical readings; this amendment pins the intent so the
@@ -109,12 +109,12 @@ decomposition task is unambiguous:
- **Why**: convention 17's letter ("vendor the `T`-generic subset"); - **Why**: convention 17's letter ("vendor the `T`-generic subset");
no crate consumer routes through the kernel-net region (the no crate consumer routes through the kernel-net region (the
producer/consumer/doors drive the typestate and the pure codecs; producer/consumer/doors drive the typestate and the pure codecs;
the local backends are first-party tokio code — ADR-004/ADR-008); the local backends are first-party tokio code, ADR-004/ADR-008);
and the BIND + `new_reply` needs touch only the extracted region and the BIND + `new_reply` needs touch only the extracted region
(BIND's driving is wrapper-side code per POC #5 — "zero upstream (BIND's driving is wrapper-side code per POC #5, "zero upstream
ask for BIND itself"). ask for BIND itself").
- **The `net` feature gate dissolves.** With no kernel-net code - **The `net` feature gate dissolves.** With no kernel-net code
in-tree, the extraction is wasm-clean by construction — no in-tree, the extraction is wasm-clean by construction; no
`#[cfg]` gate needed inside the vendored files. Features collapse `#[cfg]` gate needed inside the vendored files. Features collapse
to two: `local` (the crate's first-party socket providers and to two: `local` (the crate's first-party socket providers and
front doors, ADR-008) and `composed` (the alktunnels providers). front doors, ADR-008) and `composed` (the alktunnels providers).
@@ -123,11 +123,11 @@ decomposition task is unambiguous:
stays `default-features = false` with the wasm-clean subset stays `default-features = false` with the wasm-clean subset
(AGENTS.md convention 4); `tokio/net` rides `local` alone. (AGENTS.md convention 4); `tokio/net` rides `local` alone.
- **The carried-changes list re-reads under extraction**: (1) the - **The carried-changes list re-reads under extraction**: (1) the
net gate — dissolves (no net code); (2) BIND — stays (the typestate net gate, dissolves (no net code); (2) BIND, stays (the typestate
driving + reply#2 writer are in-tree; ADR-007); (3) the driving + reply#2 writer are in-tree; ADR-007); (3) the
`run_udp_proxy_custom` bind seam — dissolves (the handler isn't `run_udp_proxy_custom` bind seam, dissolves (the handler isn't
extracted; the typestate-driving shape POC #2 validated is the extracted; the typestate-driving shape POC #2 validated is the
crate's ASSOCIATE path); (4) `new_reply` publication — stays crate's ASSOCIATE path); (4) `new_reply` publication, stays
(it is inside the extracted codec region). Net: **two carried (it is inside the extracted codec region). Net: **two carried
changes** touch extracted code. changes** touch extracted code.
- **Extraction completeness is compiler-checked**: a missed - **Extraction completeness is compiler-checked**: a missed
@@ -139,10 +139,10 @@ decomposition task is unambiguous:
upstream's `log` calls in the extracted region are dropped or upstream's `log` calls in the extracted region are dropped or
re-pointed at the crate's `tracing` (the alk* convention); the re-pointed at the crate's `tracing` (the alk* convention); the
extracted region's `anyhow::Context` error wrapping is dropped extracted region's `anyhow::Context` error wrapping is dropped
(error surface is the crate's `thiserror` types — the vendored (error surface is the crate's `thiserror` types; the vendored
`SocksError`/`SocksServerError` stay the wire-level vocabulary); `SocksError`/`SocksServerError` stay the wire-level vocabulary);
`async-trait` is dropped (its four upstream uses are all on the `async-trait` is dropped (its four upstream uses are all on the
legacy `Authentication` layer, which is cut — the typestate's legacy `Authentication` layer, which is cut; the typestate's
`AuthMethod<T>` traits are native async-fn-in-trait); the `AuthMethod<T>` traits are native async-fn-in-trait); the
`socks4` module and the legacy `Config`/`Authentication` layer are `socks4` module and the legacy `Config`/`Authentication` layer are
out (above). These are extraction-time mechanical adjustments to out (above). These are extraction-time mechanical adjustments to
@@ -157,8 +157,8 @@ wasm check a hard gate as in alktty/alktunnels.
- OQ-SK-04 (the resolved posture; the reasoning trail), - OQ-SK-04 (the resolved posture; the reasoning trail),
OQ-SK-08 (the BIND + `new_reply` needs), OQ-SK-03 (the bind seam) OQ-SK-08 (the BIND + `new_reply` needs), OQ-SK-03 (the bind seam)
- POC #4 + POC #1 (`poc-connect-wasm-findings.md` — the measured - POC #4 + POC #1 (`poc-connect-wasm-findings.md`: the measured
gate), POC #5 (`poc-bind-findings.md` — the BIND typestate gate), POC #5 (`poc-bind-findings.md`: the BIND typestate
driving + the `new_reply` finding) driving + the `new_reply` finding)
- ADR-002 (the protocol base), ADR-007 (BIND), ADR-009 (the feature - ADR-002 (the protocol base), ADR-007 (BIND), ADR-009 (the feature
gates); `/workspace/fast-socks5` (the reference checkout, v1.0.0) gates); `/workspace/fast-socks5` (the reference checkout, v1.0.0)
@@ -3,7 +3,7 @@
## Status ## Status
Accepted (2026-09-20, Phase 1; the dialer-refuses shape is Accepted (2026-09-20, Phase 1; the dialer-refuses shape is
OQ-SK-05's evidence-backed resolution — see ADR-006 — and the OQ-SK-05's evidence-backed resolution (see ADR-006), and the
2026-09-14 virtual-subnet proposal is resolved as assembly-layer 2026-09-14 virtual-subnet proposal is resolved as assembly-layer
policy) policy)
@@ -11,15 +11,15 @@ policy)
OQ-SK-05's residual Phase 1 questions: (a) does the base crate ship OQ-SK-05's residual Phase 1 questions: (a) does the base crate ship
a per-target allowlist hook, or is target policy entirely the a per-target allowlist hook, or is target policy entirely the
dialer's concern; (b) domain-form targets (RFC 1928 ATYP 0x03) — dialer's concern; (b) domain-form targets (RFC 1928 ATYP 0x03):
resolve producer-side, refuse, or pass through unresolved; (c) the resolve producer-side, refuse, or pass through unresolved; (c) the
2026-09-14 virtual-subnet proposal (identity-scoped subnets so 2026-09-14 virtual-subnet proposal (identity-scoped subnets so
proxied resources become IP-addressable — "BND.ADDR ↔ channel id" proxied resources become IP-addressable ("BND.ADDR ↔ channel id"
translation, a hub exposing proxied resources per identity under translation, a hub exposing proxied resources per identity under
ACL). ACL).
The proposal's key property: the mechanism needs **no wire change** The proposal's key property: the mechanism needs **no wire change**;
— DST.ADDR and BND.ADDR are arbitrary RFC fields, so virtual DST.ADDR and BND.ADDR are arbitrary RFC fields, so virtual
addresses ride ordinary CONNECT/BIND requests; the translation layer addresses ride ordinary CONNECT/BIND requests; the translation layer
is the dial callback (`dial(target)` matches a virtual subnet is the dial callback (`dial(target)` matches a virtual subnet
against a per-identity resource table). against a per-identity resource table).
@@ -34,13 +34,13 @@ against a per-identity resource table).
dialer-refuses. dialer-refuses.
- **Domain-form targets: pass through unresolved to the dialer.** - **Domain-form targets: pass through unresolved to the dialer.**
Resolution is the egress's/dialer's concern (POC #2 resolved the Resolution is the egress's/dialer's concern (POC #2 resolved the
local-egress half: "the egress owns resolution" — the ssh `-D` local-egress half: "the egress owns resolution" (the ssh `-D`
semantic). The protocol layer never resolves: a wasm producer may semantic). The protocol layer never resolves: a wasm producer may
have no resolver at all, and the composed variant's targets ride have no resolver at all, and the composed variant's targets ride
the alktunnels direct op (producer-side resolution there, per the alktunnels direct op (producer-side resolution there, per
alktunnels ADR-007's own posture). fast-socks5's `dns_resolve` alktunnels ADR-007's own posture). fast-socks5's `dns_resolve`
config (and its `resolve_dns` step) is not part of the extracted config (and its `resolve_dns` step) is not part of the extracted
vendor region at all — the crate's producer never resolves vendor region at all; the crate's producer never resolves
(ADR-006/ADR-012), so the extraction drops both. (ADR-006/ADR-012), so the extraction drops both.
- **The virtual-subnet translation layer is assembly-layer policy, - **The virtual-subnet translation layer is assembly-layer policy,
not a crate feature.** The crate ships no translation-table not a crate feature.** The crate ships no translation-table
@@ -48,12 +48,12 @@ against a per-identity resource table).
wanting identity-scoped virtual subnets implements the wanting identity-scoped virtual subnets implements the
subnet→resource table there (per-identity scoping = resources + subnet→resource table there (per-identity scoping = resources +
ACL + target policy; hub exposure = the terminate-and-re-produce ACL + target policy; hub exposure = the terminate-and-re-produce
story — no new wire vocabulary). The full-tunnel composition (the story; no new wire vocabulary). The full-tunnel composition (the
host routes the subnet into the proxy via tun2proxy) is where a host routes the subnet into the proxy via tun2proxy) is where a
virtual subnet becomes routable from vanilla apps: virtual subnet becomes routable from vanilla apps:
`connect(10.x.y.z:p)` is an ordinary CONNECT the dialer `connect(10.x.y.z:p)` is an ordinary CONNECT the dialer
translates. tun2proxy's virtual-network/virtual-DNS support is translates. tun2proxy's virtual-network/virtual-DNS support is
the prior art to check *if and when* a deployment builds this — the prior art to check *if and when* a deployment builds this;
the crate's obligation is only that the seams support it, which the crate's obligation is only that the seams support it, which
ADR-004's shape already does. ADR-004's shape already does.
@@ -66,7 +66,7 @@ against a per-identity resource table).
translation layer rides the dial callback; if a deployment translation layer rides the dial callback; if a deployment
materializes with concrete needs, a translation-table helper can materializes with concrete needs, a translation-table helper can
be added additively (assembly-layer code, possibly later be added additively (assembly-layer code, possibly later
graduated into the crate behind a feature) — the seams don't graduated into the crate behind a feature); the seams don't
change. change.
- DNS posture is uniform: producer-side resolution everywhere - DNS posture is uniform: producer-side resolution everywhere
(local egress resolves; composed egress hands the name to the (local egress resolves; composed egress hands the name to the
@@ -77,11 +77,11 @@ against a per-identity resource table).
- OQ-SK-05 (the full question set + the proposal's record), - OQ-SK-05 (the full question set + the proposal's record),
OQ-SK2-07 (the deferred helper question this ADR's resolution OQ-SK2-07 (the deferred helper question this ADR's resolution
feeds), OQ-SK-01 feeds), OQ-SK-01
(the dial callback), OQ-SK-09 (the multiplexing rejection — the (the dial callback), OQ-SK-09 (the multiplexing rejection; the
virtual-subnet idea that survives is recorded in the tracker) virtual-subnet idea that survives is recorded in the tracker)
- POC #2 (`poc-udp-associate-findings.md` — the egress-owns-DNS - POC #2 (`poc-udp-associate-findings.md`: the egress-owns-DNS
resolution), ADR-004 (the seams), ADR-006 (the ACL posture), resolution), ADR-004 (the seams), ADR-006 (the ACL posture),
ADR-008 (the provider defaults) ADR-008 (the provider defaults)
- alktunnels phase-0-findings OQ-TN-08 (the target-policy - alktunnels phase-0-findings OQ-TN-08 (the target-policy
dissolution precedent); tun2proxy (`/workspace/tun2proxy` — the dissolution precedent); tun2proxy (`/workspace/tun2proxy`, the
virtual-network prior art + the endgame composition partner) virtual-network prior art + the endgame composition partner)
@@ -1,17 +1,17 @@
# ADR-013: The Targeted Fork — fast-socks5's `T`-Generic Subset Is Owned, Not Vendored # ADR-013: The Targeted Fork: fast-socks5's `T`-Generic Subset Is Owned, Not Vendored
## Status ## Status
Accepted (2026-09-22, post-extraction-review; supersedes the ADR-011 Accepted (2026-09-22, post-extraction-review; supersedes the ADR-011
maintenance posture — ADR-011 stays for the extraction mechanics and maintenance posture; ADR-011 stays for the extraction mechanics and
the cut table; the ADR-011 §Amendment is subsumed here) the cut table; the ADR-011 §Amendment is subsumed here)
## Context ## Context
ADR-011 pinned the inline-vendor posture as **extraction** (the ADR-011 pinned the inline-vendor posture as **extraction** (the
`T`-generic subset copied in-tree, kernel-net region not copied; the `T`-generic subset copied in-tree, kernel-net region not copied; the
net gate dissolving), and the vendor group — five implementation net gate dissolving), and the vendor group (five implementation
tasks plus the review gate — landed it in commits `57b107b..5b18d4d`, tasks plus the review gate) landed it in commits `57b107b..5b18d4d`,
with the review (`tasks/implementation/ with the review (`tasks/implementation/
review-vendor-extraction.md`) as the fidelity gate: the file-by-file review-vendor-extraction.md`) as the fidelity gate: the file-by-file
diff vs `/workspace/fast-socks5` v1.0.0 categorizes every hunk, and diff vs `/workspace/fast-socks5` v1.0.0 categorizes every hunk, and
@@ -20,23 +20,23 @@ every review criterion passed.
The review also made the maintenance posture's cost visible. The The review also made the maintenance posture's cost visible. The
tree is now permanently diverged from upstream in five categories: tree is now permanently diverged from upstream in five categories:
1. **The extraction cut** — the kernel-net region, `socks4`, the 1. **The extraction cut**: the kernel-net region, `socks4`, the
legacy `Config`/`Authentication` layer, `Socks5Datagram`, the legacy `Config`/`Authentication` layer, `Socks5Datagram`, the
convenience dialers, the DNS helper: gone. convenience dialers, the DNS helper: gone.
2. **The carried changes** — BIND's reply surface, the published 2. **The carried changes**: BIND's reply surface, the published
`new_reply`. `new_reply`.
3. **The panic translations** — `ReplyError::from_u8` (the review's 3. **The panic translations**: `ReplyError::from_u8` (the review's
F-1), `use_password_auth`, `reply_error`'s parse. F-1), `use_password_auth`, `reply_error`'s parse.
4. **The error-pruning adjustments** — anyhow/log/async-trait out, 4. **The error-pruning adjustments**: anyhow/log/async-trait out,
error variants pruned, `MissingTargetAddr` added. error variants pruned, `MissingTargetAddr` added.
5. **The lint-allow posture** — module-level `#[allow]`s preserving 5. **The lint-allow posture**: module-level `#[allow]`s preserving
upstream style (`u8::max_value()`, needless lifetimes, upstream style (`u8::max_value()`, needless lifetimes,
`match_like_matches_macro`, `io_other_error`, `wrong_self_ `match_like_matches_macro`, `io_other_error`, `wrong_self_
convention`, the F-4 verbatim string) to keep the diff mechanical. convention`, the F-4 verbatim string) to keep the diff mechanical.
Category 5 exists solely to keep categories 1–4 reconcilable by a Category 5 exists solely to keep categories 1–4 reconcilable by a
line-based diff. But a future re-base against an upstream release line-based diff. But a future re-base against an upstream release
was never going to be a clean mechanical diff again — it was always was never going to be a clean mechanical diff again; it was always
going to be manual reconciliation across all five categories. The going to be manual reconciliation across all five categories. The
vendor posture's maintenance apparatus (verbatim preservation, the vendor posture's maintenance apparatus (verbatim preservation, the
lint allows, the re-vendor/adjustment-list discipline) pays a lint allows, the re-vendor/adjustment-list discipline) pays a
@@ -44,7 +44,7 @@ permanent tax to serve a re-base path that would never be used.
The "vendor" framing also produced a real defect: the review's The "vendor" framing also produced a real defect: the review's
F-1 (`ReplyError::from_u8`'s upstream `unreachable!()` arm kept F-1 (`ReplyError::from_u8`'s upstream `unreachable!()` arm kept
verbatim — wire-reachable, a misbehaving peer could panic the verbatim; wire-reachable, a misbehaving peer could panic the
consumer task) escaped precisely because the vendor posture consumer task) escaped precisely because the vendor posture
maintained a second, weaker instruction set ("verbatim + adjustments maintained a second, weaker instruction set ("verbatim + adjustments
list" instead of "project conventions, unconditionally"). The list" instead of "project conventions, unconditionally"). The
@@ -52,7 +52,7 @@ fidelity-vs-conventions tension is structural to the vendor framing;
the review caught the escape, but the seam itself was the vendor the review caught the escape, but the seam itself was the vendor
framing's. framing's.
The fork point is now — post-review, post-F-1 — the cleanest line The fork point is now, post-review and post-F-1, the cleanest line
the tree will ever present: the extraction provenance is in git the tree will ever present: the extraction provenance is in git
history (`57b107b..5b18d4d`), the review is the recorded fidelity history (`57b107b..5b18d4d`), the review is the recorded fidelity
gate, and the five divergence categories are documented in ADR-011, gate, and the five divergence categories are documented in ADR-011,
@@ -76,8 +76,8 @@ vendor.md, and the review task. "The tree as of the review commit
(now gone); "socks5" would stutter with the crate name (now gone); "socks5" would stutter with the crate name
(`alksocks::socks5`) and collide with the future module structure (`alksocks::socks5`) and collide with the future module structure
(the producer/consumer/adapter layers are also SOCKS5-shaped). (the producer/consumer/adapter layers are also SOCKS5-shaped).
`rfc1928` names what the module *is* — the RFC 1928 wire `rfc1928` names what the module *is* (the RFC 1928 wire
vocabulary and the state machines — permanent and collision-free. vocabulary and the state machines), permanent and collision-free.
- **The lint-allow posture dissolves.** The module-level `#[allow]`s - **The lint-allow posture dissolves.** The module-level `#[allow]`s
and the "verbatim lint posture" module-doc paragraphs are removed; and the "verbatim lint posture" module-doc paragraphs are removed;
the suppressed smells are fixed per project conventions: the suppressed smells are fixed per project conventions:
@@ -100,7 +100,7 @@ vendor.md, and the review task. "The tree as of the review commit
pins bytes, not upstream text, so it survives the rename pins bytes, not upstream text, so it survives the rename
untouched. Textual-diff discipline is retired; behavioral untouched. Textual-diff discipline is retired; behavioral
differential testing is retained. differential testing is retained.
- **The cut table is re-read as the not-forked inventory** — the - **The cut table is re-read as the not-forked inventory**, the
map of what to hand-port if a future need surfaces (e.g. the map of what to hand-port if a future need surfaces (e.g. the
kernel-net region for a standalone vanilla-server example). The kernel-net region for a standalone vanilla-server example). The
hand-port path replaces the re-vendor path: port against the hand-port path replaces the re-vendor path: port against the
@@ -109,7 +109,7 @@ vendor.md, and the review task. "The tree as of the review commit
- **The honest cost**: the cheap upstream-fix path closes. A future - **The honest cost**: the cheap upstream-fix path closes. A future
fast-socks5 fix (a reply-code mapping correction, a codec fix) fast-socks5 fix (a reply-code mapping correction, a codec fix)
arrives as a hand-port against the reference checkout, not a arrives as a hand-port against the reference checkout, not a
diff absorb. Risk is assessed low — fast-socks5 is a quiet diff absorb. Risk is assessed low; fast-socks5 is a quiet
project, and the extracted surface is the well-trodden RFC project, and the extracted surface is the well-trodden RFC
1928 path. 1928 path.
@@ -118,13 +118,13 @@ vendor.md, and the review task. "The tree as of the review commit
- **Positive**: one instruction set (project conventions, - **Positive**: one instruction set (project conventions,
unconditionally); the lint-allow tax and the adjustment-list unconditionally); the lint-allow tax and the adjustment-list
apparatus dissolve; smells are fixed at the only cost that apparatus dissolve; smells are fixed at the only cost that
matters — a one-time cleanup verified by the existing suite. matters: a one-time cleanup verified by the existing suite.
The F-1 seam class (conventions-vs-fidelity gaps) is closed The F-1 seam class (conventions-vs-fidelity gaps) is closed
structurally. structurally.
- **Positive**: the module name is stable for the crate's future - **Positive**: the module name is stable for the crate's future
(no `alksocks::socks5` stutter, no `socks5::producer` collision (no `alksocks::socks5` stutter, no `socks5::producer` collision
pressure). pressure).
- **Negative (accepted)**: the cheap upstream-fix path closes — a - **Negative (accepted)**: the cheap upstream-fix path closes; a
future upstream fix is a hand-port against the reference future upstream fix is a hand-port against the reference
checkout, guarded by the differential suite. Assessed low-risk checkout, guarded by the differential suite. Assessed low-risk
(quiet upstream, well-trodden RFC surface). (quiet upstream, well-trodden RFC surface).
@@ -139,10 +139,10 @@ vendor.md, and the review task. "The tree as of the review commit
## References ## References
- ADR-011 (the extraction mechanics, the cut table, the carried - ADR-011 (the extraction mechanics, the cut table, the carried
changes — the mechanics remain authoritative), ADR-002 (the changes; the mechanics remain authoritative), ADR-002 (the
protocol base), ADR-009 (feature gates, no `net` feature) protocol base), ADR-009 (feature gates, no `net` feature)
- `tasks/implementation/review-vendor-extraction.md` (the fidelity - `tasks/implementation/review-vendor-extraction.md` (the fidelity
gate + F-1), `docs/architecture/rfc1928.md` (the owned-core gate + F-1), `docs/architecture/rfc1928.md` (the owned-core
spec) spec)
- `/workspace/fast-socks5` (v1.0.0, MIT — the behavioral - `/workspace/fast-socks5` (v1.0.0, MIT, the behavioral
reference for differential testing) reference for differential testing)
@@ -1,10 +1,10 @@
# ADR-014: The Idle-Timeout Posture — Teardown-Governed Phases, No Protocol-Layer Idle Bound # ADR-014: The Idle-Timeout Posture: Teardown-Governed Phases, No Protocol-Layer Idle Bound
## Status ## Status
Accepted (2026-09-24, review 002 C-2's decision task — the deliverable Accepted (2026-09-24, review 002 C-2's decision task: the deliverable
is this decision, not code; it lands before the batch-2 `local`/ is this decision, not code; it lands before the batch-2 `local`/
`composed` doors exist, whose tasks may depend on it — ADR-008/ `composed` doors exist, whose tasks may depend on it, per ADR-008/
ADR-009's provider posture) ADR-009's provider posture)
## Context ## Context
@@ -13,33 +13,33 @@ Review 002's C-2 (major): the pre-command RFC handshake and the
association have no timeout surface. After the channel opens, the association have no timeout surface. After the channel opens, the
pump handler's spawned task parks on the RFC handshake pump handler's spawned task parks on the RFC handshake
(`Socks5ServerProtocol::accept_no_auth` → `read_command`), and the (`Socks5ServerProtocol::accept_no_auth` → `read_command`), and the
datagram stage's loop exits only on EOF/error/data — no timer exists datagram stage's loop exits only on EOF/error/data; no timer exists
anywhere in the protocol layer. The alkcall 10s establishment bound anywhere in the protocol layer. The alkcall 10s establishment bound
(`register_openable_with_establisher`'s default, `timeout: None`, (`register_openable_with_establisher`'s default, `timeout: None`,
alkcall ADR-049 §2) governs only the open-op round trip, not the alkcall ADR-049 §2) governs only the open-op round trip, not the
in-stream conversation. A silent peer therefore holds — per idle in-stream conversation. A silent peer therefore holds, per idle
channel — one spawned task and one channel entry (bounded buffers, channel, one spawned task and one channel entry (bounded buffers,
ADR-040) indefinitely. BIND's quiet wait got an explicit decision ADR-040) indefinitely. BIND's quiet wait got an explicit decision
(ADR-007: teardown-governed — "FTP-length waits stay legal"); the (ADR-007: teardown-governed, "FTP-length waits stay legal"); the
handshake and the association lifetime got "inherited from the handshake and the association lifetime got "inherited from the
channel" — implicit, never decided. channel", implicit, never decided.
The mitigations are real: the 256-channel per-connection cap The mitigations are real: the 256-channel per-connection cap
(ADR-040) and the per-identity cap (ADR-041) bound the count, (ADR-040) and the per-identity cap (ADR-041) bound the count,
per-session memory is bounded, and teardown fires on channel close — per-session memory is bounded, and teardown fires on channel close;
nothing leaks, nothing grows. The aggregate remains: any identity nothing leaks, nothing grows. The aggregate remains: any identity
with `socks5:open` can hold 256 parked tasks + 256 channel entries with `socks5:open` can hold 256 parked tasks + 256 channel entries
indefinitely *without sending a byte*. And batch 2's providers indefinitely *without sending a byte*. And batch 2's providers
(`local`, `composed`, ADR-008) are about to route *vanilla clients* (`local`, `composed`, ADR-008) are about to route *vanilla clients*
through these same phases via the front doors, where the peer is a through these same phases via the front doors, where the peer is a
kernel socket often outside the channel-cap regime entirely — a kernel socket often outside the channel-cap regime entirely; a
slowloris-shaped hold (open, greet, stop) becomes the assembly slowloris-shaped hold (open, greet, stop) becomes the assembly
layer's problem, and the protocol layer is where a timeout seam would layer's problem, and the protocol layer is where a timeout seam would
live. live.
The review offered two options: (1) idle timeout is the substrate's/ The review offered two options: (1) idle timeout is the substrate's/
assembly's job — the protocol layer never times out, the DoS posture assembly's job: the protocol layer never times out, the DoS posture
recorded explicitly; (2) a producer-side idle bound — an optional recorded explicitly; (2) a producer-side idle bound: an optional
per-session inactivity timeout at the pump level (config on per-session inactivity timeout at the pump level (config on
`Socks5ProducerBuilder`), racing the handshake/stage reads against a `Socks5ProducerBuilder`), racing the handshake/stage reads against a
sleep, timing out as a clean EOF-shaped teardown. sleep, timing out as a clean EOF-shaped teardown.
@@ -51,61 +51,61 @@ Two facts sharpen the choice beyond the review's sketch:
idle CONNECT tunnel (`pump_bidi` parked on a quiet target), a idle CONNECT tunnel (`pump_bidi` parked on a quiet target), a
quiet-but-alive association (association lifetime = channel quiet-but-alive association (association lifetime = channel
lifetime, wire.md §UDP ASSOCIATE), BIND's quiet wait (ADR-007). lifetime, wire.md §UDP ASSOCIATE), BIND's quiet wait (ADR-007).
The resource cost is identical to the pre-command hold — one The resource cost is identical to the pre-command hold, one
parked task + one channel entry. A handshake-only idle bound parked task + one channel entry. A handshake-only idle bound
protects only the pre-auth sliver while the dominant hold (the protects only the pre-auth sliver while the dominant hold (the
post-reply idle) stays contractually open — incoherent; a bound on post-reply idle) stays contractually open; incoherent. A bound on
both is a full inactivity timeout, which contradicts the landed both is a full inactivity timeout, which contradicts the landed
contracts (association lifetime = channel lifetime; FTP-length contracts (association lifetime = channel lifetime; FTP-length
BIND waits stay legal, ADR-007). The unbounded-time aggregate is BIND waits stay legal, ADR-007). The unbounded-time aggregate is
therefore a property of every idle session the protocol serves — therefore a property of every idle session the protocol serves:
bounded by the cap regime, not by a timer. bounded by the cap regime, not by a timer.
- **The two sides differ in composability.** The consumer sessions - **The two sides differ in composability.** The consumer sessions
are plain futures at the caller — a caller wanting an idle bound are plain futures at the caller: a caller wanting an idle
wraps the constructor (or the session's reads) in bound wraps the constructor (or the session's reads) in
`tokio::time::timeout` itself; no decision is needed consumer-side. `tokio::time::timeout` itself; no decision is needed consumer-side.
The producer's parked task lives inside the alkcall-spawned open The producer's parked task lives inside the alkcall-spawned open
handler — not caller-composable; only the producer-side question handler, not caller-composable; only the producer-side question
needed an ADR. needed an ADR.
Also recorded (an upstream-fidelity correction to the review's Also recorded (an upstream-fidelity correction to the review's
sketch): upstream's connect-timeout path maps its *dial* timeout to sketch): upstream's connect-timeout path maps its *dial* timeout to
`ReplyError::ConnectionTimeout` → reply code 0x06 (TTL expired), not `ReplyError::ConnectionTimeout` → reply code 0x06 (TTL expired), not
0x01 as the sketch suggested — the mapping table lives in 0x01 as the sketch suggested; the mapping table lives in
`src/rfc1928/mod.rs` (carried from `/workspace/fast-socks5` `src/rfc1928/mod.rs` (carried from `/workspace/fast-socks5`
`util/stream.rs`). Relevant only if option 2 is ever built. `util/stream.rs`). Relevant only if option 2 is ever built.
## Decision ## Decision
**Option 1 — the protocol layer never times out.** Every phase of the **Option 1: the protocol layer never times out.** Every phase of the
RFC 1928 conversation is teardown-governed: the pre-command RFC 1928 conversation is teardown-governed: the pre-command
handshake, the BIND quiet wait, and the association lifetime end on handshake, the BIND quiet wait, and the association lifetime end on
the channels-level sentinels (EOF / garbage / error), never on a the channels-level sentinels (EOF / garbage / error), never on a
protocol-layer inactivity timer. The posture is substrate-uniform — protocol-layer inactivity timer. The posture is substrate-uniform:
the same for a channel peer and for a kernel socket a door relays. the same for a channel peer and for a kernel socket a door relays.
- **No inactivity bound exists in `src/`.** The handshake and stage - **No inactivity bound exists in `src/`.** The handshake and stage
reads park like any other phase: no idle timer, no keepalive, no reads park like any other phase: no idle timer, no keepalive, no
in-band expiry vocabulary. "No in-band control vocabulary" in-band expiry vocabulary. "No in-band control vocabulary"
(wire.md) holds — this decision adds none. (wire.md) holds; this decision adds none.
- **The DoS posture is explicit and layered** (the ADR-010 pattern: - **The DoS posture is explicit and layered** (the ADR-010 pattern:
ACL → per-connection cap → per-identity cap → door/dial policy): ACL → per-connection cap → per-identity cap → door/dial policy):
- **Channels shapes**: parked-task count is bounded by the - **Channels shapes**: parked-task count is bounded by the
per-connection cap (ADR-040) and the per-identity cap (ADR-041); per-connection cap (ADR-040) and the per-identity cap (ADR-041);
per-session memory is bounded; teardown fires on channel close. per-session memory is bounded; teardown fires on channel close.
Time is unbounded by design — accepted, the same posture every Time is unbounded by design; accepted, the same posture every
idle-but-open session already has. idle-but-open session already has.
- **Doors** (the batch-2 obligation this ADR records before they - **Doors** (the batch-2 obligation this ADR records before they
exist): an accepted TCP connection is *not a channel* — the cap exist): an accepted TCP connection is *not a channel*; the cap
regime does not bound a door's accepted-socket hold. The door's regime does not bound a door's accepted-socket hold. The door's
accept path owns its defense; the batch-2 door author inherits accept path owns its defense; the batch-2 door author inherits
these obligations knowingly (the wire.md §UDP ASSOCIATE these obligations knowingly (the wire.md §UDP ASSOCIATE
"inherits this bound knowingly" pattern): "inherits this bound knowingly" pattern):
1. an **accept-stage idle bound** — an accepted connection that 1. an **accept-stage idle bound**: an accepted connection that
sends no first byte within a configured window is closed sends no first byte within a configured window is closed
(door-observable, protocol-agnostic; this is the slowloris (door-observable, protocol-agnostic; this is the slowloris
sliver the protocol layer will not bound); sliver the protocol layer will not bound);
2. **per-credential / per-peer session caps** — the RFC 1929 2. **per-credential / per-peer session caps**: the RFC 1929
cred-bearing door's credential→identity config table cred-bearing door's credential→identity config table
(ADR-005) is the natural knob; (ADR-005) is the natural knob;
3. **accept-queue / relay-task bounds** on the door's own accept 3. **accept-queue / relay-task bounds** on the door's own accept
@@ -115,10 +115,10 @@ the same for a channel peer and for a kernel socket a door relays.
the producer-side local backend's accept loop, and a composed the producer-side local backend's accept loop, and a composed
bind-first listener's front. bind-first listener's front.
- The protocol layer neither provides nor re-derives these knobs - The protocol layer neither provides nor re-derives these knobs
(limits are inherited, not redefined — AGENTS.md convention 10); (limits are inherited, not redefined, per AGENTS.md convention 10);
no timeout helper ships speculatively. no timeout helper ships speculatively.
- **Relationship to ADR-007 (the BIND quiet wait): same regime.** - **Relationship to ADR-007 (the BIND quiet wait): same regime.**
ADR-007 decided the quiet wait's ends as part of BIND's shape — the ADR-007 decided the quiet wait's ends as part of BIND's shape: the
`select!` needed exactly two exits, and "FTP-length waits stay `select!` needed exactly two exits, and "FTP-length waits stay
legal" was the explicit call. ADR-014 records the *same* resolution legal" was the explicit call. ADR-014 records the *same* resolution
for the phases ADR-007 left implicit. The principle is one and the for the phases ADR-007 left implicit. The principle is one and the
@@ -129,33 +129,33 @@ the same for a channel peer and for a kernel socket a door relays.
difference is only decidedness: ADR-007 decided one phase's ends by difference is only decidedness: ADR-007 decided one phase's ends by
designing BIND; ADR-014 decides the rest by recording the inherited designing BIND; ADR-014 decides the rest by recording the inherited
resolution as the decision. No new regime is introduced. resolution as the decision. No new regime is introduced.
- **Revisitable — not a one-way door.** A producer-side idle seam is - **Revisitable, not a one-way door.** A producer-side idle seam is
additive if a deployment ever proves this posture insufficient: an additive if a deployment ever proves this posture insufficient: an
optional per-session inactivity bound (builder config defaulting to optional per-session inactivity bound (builder config defaulting to
`None` = this posture), enforced by racing the handshake/stage `None` = this posture), enforced by racing the handshake/stage
reads against a sleep timer (`tokio::time` is already in the reads against a sleep timer (`tokio::time` is already in the
wasm-clean dependency subset), timing out as a clean EOF-shaped wasm-clean dependency subset), timing out as a clean EOF-shaped
teardown. No wire change, no API break, no spec contradiction — teardown. No wire change, no API break, no spec contradiction:
the spec lines record the *default* posture, and an opt-in bound the spec lines record the *default* posture, and an opt-in bound
contradicts nothing. The RFC has no timeout reply code; a pre-reply contradicts nothing. The RFC has no timeout reply code; a pre-reply
timeout, if the seam is ever built, would surface as the EOF shape timeout, if the seam is ever built, would surface as the EOF shape
(or the closest mapped code — upstream's dial-timeout precedent is (or the closest mapped code; upstream's dial-timeout precedent is
0x06, see Context). Revisit trigger: a deployment that cannot 0x06, see Context). Revisit trigger: a deployment that cannot
enforce the bound at its doors or assembly — a peer class outside enforce the bound at its doors or assembly (a peer class outside
both the cap regime and any door policy — with measured evidence both the cap regime and any door policy), with measured evidence
the aggregate hold is unacceptable. the aggregate hold is unacceptable.
## Consequences ## Consequences
- No code changes this round; the posture is recorded. The spec lines - No code changes this round; the posture is recorded. The spec lines
(wire.md §UDP ASSOCIATE's association-lifetime bullet; (wire.md §UDP ASSOCIATE's association-lifetime bullet;
producer.md §The Open Handler) carry the decided posture — the producer.md §The Open Handler) carry the decided posture; the
implicit residue is gone. implicit residue is gone.
- The batch-2 door tasks inherit the recorded accept-path obligations; - The batch-2 door tasks inherit the recorded accept-path obligations;
this ADR is the timeout-posture gate those tasks may depend on this ADR is the timeout-posture gate those tasks may depend on
(it precedes any `local`/`composed` door work). (it precedes any `local`/`composed` door work).
- Review 002 C-2 closes as decided. The adjacent S-1 residual (the - Review 002 C-2 closes as decided. The adjacent S-1 residual (the
datagram stage's hot-`Ok(None)` tolerance — unbounded *rate*, not datagram stage's hot-`Ok(None)` tolerance: unbounded *rate*, not
unbounded *time*) stays its own accepted-uncovered posture; this unbounded *time*) stays its own accepted-uncovered posture; this
decision does not touch it. decision does not touch it.
- Consumer-side timeout policy stays where it always was: caller- - Consumer-side timeout policy stays where it always was: caller-
@@ -167,7 +167,7 @@ the same for a channel peer and for a kernel socket a door relays.
- docs/reviews/002-protocol-core-implementation-review.md §C-2 (the - docs/reviews/002-protocol-core-implementation-review.md §C-2 (the
finding), §S-1 (the adjacent rate posture), §N-2 (the finding), §S-1 (the adjacent rate posture), §N-2 (the
"inherits this bound knowingly" pattern this ADR extends) "inherits this bound knowingly" pattern this ADR extends)
- [ADR-007](007-bind-in-base.md) (the quiet wait's decision — the - [ADR-007](007-bind-in-base.md) (the quiet wait's decision; the
contrast case), [ADR-010](010-channel-capacity.md) (the contrast case), [ADR-010](010-channel-capacity.md) (the
layered-defense + config-not-protocol pattern this follows), layered-defense + config-not-protocol pattern this follows),
[ADR-008](008-provider-defaults.md) / [ADR-009](009-feature-gates.md) [ADR-008](008-provider-defaults.md) / [ADR-009](009-feature-gates.md)
@@ -179,7 +179,7 @@ the same for a channel peer and for a kernel socket a door relays.
- [wire.md](../wire.md) §UDP ASSOCIATE, - [wire.md](../wire.md) §UDP ASSOCIATE,
[producer.md](../producer.md) §The Open Handler (the spec lines [producer.md](../producer.md) §The Open Handler (the spec lines
updated) updated)
- POC findings (the stage's measured shapes — the "sufficient for - POC findings (the stage's measured shapes; the "sufficient for
relay postures" lineage) relay postures" lineage)
- `/workspace/fast-socks5` (`src/util/stream.rs` — the dial-timeout - `/workspace/fast-socks5` (`src/util/stream.rs`: the dial-timeout
mapping precedent, 0x06) mapping precedent, 0x06)
+40 -40
View File
@@ -1,46 +1,46 @@
--- ---
status: draft status: draft
last_updated: 2026-09-28 (OQ-SK2-09 recorded — the UDP door's last_updated: 2026-09-28 (OQ-SK2-09 recorded: the UDP door's
peer-pin posture; option 2 landed in 0.1.0, option 3 deferred; peer-pin posture; option 2 landed in 0.1.0, option 3 deferred;
OQ-SK2-06's trigger ownership checked — genuinely external; the OQ-SK2-06's trigger ownership checked, genuinely external; the
OQ-SK ledger OQ-SK-01..09 promoted 2026-09-20; new questions OQ-SK ledger OQ-SK-01..09 promoted 2026-09-20; new questions
continue as OQ-SK2-06..) continue as OQ-SK2-06..)
--- ---
# alksocks — Open Questions # alksocks: Open Questions
Centralized tracker. The Phase 0 OQ ledger (`docs/research/ Centralized tracker. The Phase 0 OQ ledger (`docs/research/
phase-0.md`, OQ-SK-01..09) is promoted here with statuses current as phase-0.md`, OQ-SK-01..09) is promoted here with statuses current as
of the Phase 1 spec; new Phase 1 questions continue the OQ-SK2 of the Phase 1 spec; new Phase 1 questions continue the OQ-SK2
numbering (SK2 = the architecture-set ledger, distinct from the numbering (SK2 = the architecture-set ledger, distinct from the
Phase 0 ledger's OQ-SK- prefix, which stays historical). Note: the Phase 0 ledger's OQ-SK- prefix, which stays historical). Note: the
SK2 numbering is not contiguous from 01 — OQ-SK2-01..05 were SK2 numbering is not contiguous from 01: OQ-SK2-01..05 were
consumed in working drafts before the first committed set; the consumed in working drafts before the first committed set; the
sequence starts at the numbers in use. Format per sequence starts at the numbers in use. Format per
`docs/sdd_process.md`. `docs/sdd_process.md`.
## Theme 1: Wire format and phases ## Theme 1: Wire format and phases
### OQ-SK-01: Producer dial policy — what dials the target? ### OQ-SK-01: Producer dial policy (what dials the target?)
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-01) - **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-01)
- **Status**: resolved — [ADR-004](decisions/004-dial-callback-shape.md) - **Status**: resolved: [ADR-004](decisions/004-dial-callback-shape.md)
(the shape), [ADR-008](decisions/008-provider-defaults.md) (the (the shape), [ADR-008](decisions/008-provider-defaults.md) (the
provider set) provider set)
- **Priority**: high - **Priority**: high
- **Resolution**: 2026-09-20 (Phase 1). The dialer-trait-vs-callback - **Resolution**: 2026-09-20 (Phase 1). The dialer-trait-vs-callback
half closed on the POC evidence (function-not-trait at three half closed on the POC evidence (function-not-trait at three
seams — DialFn/EgressFactory/BindAcceptFactory, boxed halves, seams: DialFn/EgressFactory/BindAcceptFactory, boxed halves,
ownership rides the handle). The provider-default half: the base ownership rides the handle). The provider-default half: the base
crate ships local-socket providers (`local`) and composed crate ships local-socket providers (`local`) and composed
providers (`composed`, the alktunnels direct op); no implicit providers (`composed`, the alktunnels direct op); no implicit
dialer — registration always takes the callbacks explicitly. dialer; registration always takes the callbacks explicitly.
- **Cross-references**: OQ-SK-03, OQ-SK-08, ADR-004, ADR-008 - **Cross-references**: OQ-SK-03, OQ-SK-08, ADR-004, ADR-008
### OQ-SK-03: UDP ASSOCIATE without binding ### OQ-SK-03: UDP ASSOCIATE without binding
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-03) - **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-03)
- **Status**: resolved — [ADR-003](decisions/003-udp-handle-swap.md) - **Status**: resolved: [ADR-003](decisions/003-udp-handle-swap.md)
- **Priority**: high - **Priority**: high
- **Resolution**: 2026-09-20 (Phase 1; baseline settled 2026-09-13, - **Resolution**: 2026-09-20 (Phase 1; baseline settled 2026-09-13,
POC #2 validated 2026-09-14). The handle swap: one channel per POC #2 validated 2026-09-14). The handle swap: one channel per
@@ -52,15 +52,15 @@ sequence starts at the numbers in use. Format per
dedup pinned by test). dedup pinned by test).
- **Cross-references**: OQ-SK-01, ADR-003, ADR-008 - **Cross-references**: OQ-SK-01, ADR-003, ADR-008
### OQ-SK-08: BIND — the accept-side command ### OQ-SK-08: BIND (the accept-side command)
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-08) - **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-08)
- **Status**: resolved — [ADR-007](decisions/007-bind-in-base.md) - **Status**: resolved: [ADR-007](decisions/007-bind-in-base.md)
- **Priority**: high - **Priority**: high
- **Resolution**: 2026-09-20 (Phase 1; POC #5 validated 2026-09-15). - **Resolution**: 2026-09-20 (Phase 1; POC #5 validated 2026-09-15).
BIND ships in the base crate, phase-model style; the accept BIND ships in the base crate, phase-model style; the accept
variants: local listener (default, faithful), composed variants: local listener (default, faithful), composed
(alktunnels bind-first listen + forwarded op — vanilla-fidelity (alktunnels bind-first listen + forwarded op, vanilla-fidelity
since the graduation), wrapper-aware (in-band default). The since the graduation), wrapper-aware (in-band default). The
establishment bound covers only the open-op round trip; the establishment bound covers only the open-op round trip; the
accept wait lives in the teardown-governed pump phase. accept wait lives in the teardown-governed pump phase.
@@ -68,10 +68,10 @@ sequence starts at the numbers in use. Format per
## Theme 2: Crate architecture ## Theme 2: Crate architecture
### OQ-SK-02: Auth mapping — identity seam vs in-band SOCKS auth ### OQ-SK-02: Auth mapping (identity seam vs in-band SOCKS auth)
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-02) - **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-02)
- **Status**: resolved — [ADR-005](decisions/005-auth-split-by-door.md) - **Status**: resolved: [ADR-005](decisions/005-auth-split-by-door.md)
- **Priority**: medium - **Priority**: medium
- **Resolution**: 2026-09-20 (Phase 1; the posture settled - **Resolution**: 2026-09-20 (Phase 1; the posture settled
2026-09-13). "Both," split by the door: producer = identity seam 2026-09-13). "Both," split by the door: producer = identity seam
@@ -85,7 +85,7 @@ sequence starts at the numbers in use. Format per
### OQ-SK-04: fast-socks5 wasm posture ### OQ-SK-04: fast-socks5 wasm posture
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-04) - **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-04)
- **Status**: resolved — [ADR-011](decisions/011-inline-vendor.md) - **Status**: resolved: [ADR-011](decisions/011-inline-vendor.md)
(the extraction mechanics), [ADR-013](decisions/ (the extraction mechanics), [ADR-013](decisions/
013-targeted-fork.md) (the ownership posture) 013-targeted-fork.md) (the ownership posture)
- **Priority**: high - **Priority**: high
@@ -97,14 +97,14 @@ sequence starts at the numbers in use. Format per
changes (BIND, `new_reply` publication); fork point = the tree as changes (BIND, `new_reply` publication); fork point = the tree as
of the extraction-review commit (`5b18d4d`), module `src/rfc1928/`; of the extraction-review commit (`5b18d4d`), module `src/rfc1928/`;
differential testing against `/workspace/fast-socks5` (behavioral differential testing against `/workspace/fast-socks5` (behavioral
reference — the cheap upstream-fix path closes; a future upstream reference; the cheap upstream-fix path closes; a future upstream
fix is a hand-port, guarded by the suite). fix is a hand-port, guarded by the suite).
- **Cross-references**: ADR-002, ADR-009, ADR-011, ADR-013 - **Cross-references**: ADR-002, ADR-009, ADR-011, ADR-013
### OQ-SK-07: POC register ### OQ-SK-07: POC register
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-07) - **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-07)
- **Status**: resolved — the register is closed (process artifact, - **Status**: resolved: the register is closed (process artifact,
not a design question) not a design question)
- **Priority**: low - **Priority**: low
- **Resolution**: 2026-09-14 (Phase 0). All POCs resolved: #1, #2, - **Resolution**: 2026-09-14 (Phase 0). All POCs resolved: #1, #2,
@@ -117,13 +117,13 @@ sequence starts at the numbers in use. Format per
### OQ-SK-09: Channel capacity for proxy workloads (the 256 default) ### OQ-SK-09: Channel capacity for proxy workloads (the 256 default)
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-09) - **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-09)
- **Status**: resolved — [ADR-010](decisions/010-channel-capacity.md) - **Status**: resolved: [ADR-010](decisions/010-channel-capacity.md)
- **Priority**: medium - **Priority**: medium
- **Resolution**: 2026-09-20 (Phase 1). No wire change (rejected - **Resolution**: 2026-09-20 (Phase 1). No wire change (rejected
permanently); assembly-layer config is the mechanism; the crate permanently); assembly-layer config is the mechanism; the crate
docs carry the proxy-workload recommendation with the memory docs carry the proxy-workload recommendation with the memory
arithmetic; in-channel port multiplexing rejected (the rationale + arithmetic; in-channel port multiplexing rejected (the rationale +
the revisit trigger recorded — the additive escape is a new the revisit trigger recorded; the additive escape is a new
multiplexed ALPN). multiplexed ALPN).
- **Cross-references**: OQ-SK2-08, ADR-010 - **Cross-references**: OQ-SK2-08, ADR-010
@@ -132,7 +132,7 @@ sequence starts at the numbers in use. Format per
### OQ-SK-05: Target policy and egress scoping ### OQ-SK-05: Target policy and egress scoping
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-05) - **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-05)
- **Status**: resolved — [ADR-006](decisions/006-access-control.md) - **Status**: resolved: [ADR-006](decisions/006-access-control.md)
(the gate posture), [ADR-012](decisions/012-target-policy.md) (the gate posture), [ADR-012](decisions/012-target-policy.md)
(target policy + the virtual-subnet layer) (target policy + the virtual-subnet layer)
- **Priority**: high - **Priority**: high
@@ -147,14 +147,14 @@ sequence starts at the numbers in use. Format per
### OQ-SK-06: noq client support ### OQ-SK-06: noq client support
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-06) - **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-06)
- **Status**: resolved — transferred to the alknet rewrite (the - **Status**: resolved: transferred to the alknet rewrite (the
2026-09-14 scope decision) 2026-09-14 scope decision)
- **Priority**: low - **Priority**: low
- **Resolution**: 2026-09-14 (Phase 0). The quinn/noq - **Resolution**: 2026-09-14 (Phase 0). The quinn/noq
`AsyncUdpSocket` client wrapper is alknet's ancestor-shaped `AsyncUdpSocket` client wrapper is alknet's ancestor-shaped
component (ADR-090 + the quinn-proxy POC live there); it neither component (ADR-090 + the quinn-proxy POC live there); it neither
produces nor consumes channels. This crate's client story is the produces nor consumes channels. This crate's client story is the
channels-native sessions (POC-validated) — complete without it. channels-native sessions (POC-validated), complete without it.
- **Cross-references**: ADR-002 (the base), phase-0 §Scope - **Cross-references**: ADR-002 (the base), phase-0 §Scope
### OQ-SK2-06: The sandboxed-wasm-consumer adapter story ### OQ-SK2-06: The sandboxed-wasm-consumer adapter story
@@ -166,27 +166,27 @@ sequence starts at the numbers in use. Format per
- **Priority**: low - **Priority**: low
- **Impacts**: how much wasm surface to exercise in the default - **Impacts**: how much wasm surface to exercise in the default
crate (a product/docs input). Blocks nothing in the protocol crate (a product/docs input). Blocks nothing in the protocol
design — the wasm-clean invariant (ADR-009) holds regardless. design; the wasm-clean invariant (ADR-009) holds regardless.
- **Blocked on**: a concrete sandboxed-adapter use case — a TS/Python - **Blocked on**: a concrete sandboxed-adapter use case: a TS/Python
(or other) adapter embedding the alksocks consumer session in a (or other) adapter embedding the alksocks consumer session in a
wasm runtime. Tracker task id: wasm runtime. Tracker task id:
`architecture/oq-sk2-06-wasm-adapter-story` `architecture/oq-sk2-06-wasm-adapter-story`
(`tasks/architecture/oq-sk2-06-wasm-adapter-story.md`). (`tasks/architecture/oq-sk2-06-wasm-adapter-story.md`).
- **Trigger ownership (checked 2026-09-28)**: the trigger is - **Trigger ownership (checked 2026-09-28)**: the trigger is
genuinely external — no planned suite-internal surface (alktty, genuinely external: no planned suite-internal surface (alktty,
alktunnels, alkcall) needs a sandboxed alksocks consumer, and the alktunnels, alkcall) needs a sandboxed alksocks consumer, and the
`alknodes` assembly concept carries no named wasm-adapter plan. `alknodes` assembly concept carries no named wasm-adapter plan.
Nothing suite-side waits on this OQ, and this OQ waits on nothing Nothing suite-side waits on this OQ, and this OQ waits on nothing
suite-side (the adapter's substrate — the wasm-clean default crate suite-side (the adapter's substrate, the wasm-clean default crate,
— is complete and continuously gate-verified; a downstream glue is complete and continuously gate-verified; a downstream glue
crate can embed it today with zero further alksocks work). The crate can embed it today with zero further alksocks work). The
Schrödinger-bind check (both sides deferring to each other Schrödinger-bind check (both sides deferring to each other
forever) came up empty; the OQ stays an unowned external trigger forever) came up empty; the OQ stays an unowned external trigger
by verified default. Re-check the ownership claim if a product by verified default. Re-check the ownership claim if a product
surface names a sandboxed adapter — the amendment then converts surface names a sandboxed adapter; the amendment then converts
this to an owned dependency. this to an owned dependency.
- **Resolution**: Not yet decidable. The use case isn't concrete yet - **Resolution**: Not yet decidable. The use case isn't concrete yet:
— the adapter story is a product input whose shape would tell us the adapter story is a product input whose shape would tell us
whether the wasm consumer needs surface beyond the whether the wasm consumer needs surface beyond the
channels-native session (e.g. bindings ergonomics). The channels-native session (e.g. bindings ergonomics). The
architecture decision (wasm-clean default, ADR-009) is already architecture decision (wasm-clean default, ADR-009) is already
@@ -200,7 +200,7 @@ sequence starts at the numbers in use. Format per
- **Status**: deferred(scope) - **Status**: deferred(scope)
- **Door type**: two-way - **Door type**: two-way
- **Priority**: low - **Priority**: low
- **Impacts**: nothing in the crate — the seams already support the - **Impacts**: nothing in the crate; the seams already support the
translation layer (the dial callback, ADR-004/ADR-012). This translation layer (the dial callback, ADR-004/ADR-012). This
tracks whether a *translation-table helper* should graduate into tracks whether a *translation-table helper* should graduate into
the crate if a deployment materializes with concrete needs. the crate if a deployment materializes with concrete needs.
@@ -209,7 +209,7 @@ sequence starts at the numbers in use. Format per
at that point). Tracker task id: at that point). Tracker task id:
`architecture/oq-sk2-07-virtual-subnet-helper` `architecture/oq-sk2-07-virtual-subnet-helper`
(`tasks/architecture/oq-sk2-07-virtual-subnet-helper.md`). (`tasks/architecture/oq-sk2-07-virtual-subnet-helper.md`).
- **Resolution**: Not yet decidable. The use case isn't concrete — - **Resolution**: Not yet decidable. The use case isn't concrete:
the mechanism is assembly-layer policy today (ADR-012), and a the mechanism is assembly-layer policy today (ADR-012), and a
crate-level helper would need real deployment requirements to crate-level helper would need real deployment requirements to
shape (subnet sizing, per-identity tables, hub exposure). shape (subnet sizing, per-identity tables, hub exposure).
@@ -236,7 +236,7 @@ sequence starts at the numbers in use. Format per
the pre-publish session's escalation (2026-09-28): the residual is the pre-publish session's escalation (2026-09-28): the residual is
a real deployment concern (NAT is common), and the RFC's own drop a real deployment concern (NAT is common), and the RFC's own drop
rule was unimplemented rule was unimplemented
- **Status**: partially resolved — option 2 landed in 0.1.0 (commit - **Status**: partially resolved: option 2 landed in 0.1.0 (commit
`31fb11c`); option 3 deferred (below) `31fb11c`); option 3 deferred (below)
- **Door type**: two-way (additive; the current shape is RFC-faithful) - **Door type**: two-way (additive; the current shape is RFC-faithful)
- **Priority**: low (post-fix: the common cases are covered) - **Priority**: low (post-fix: the common cases are covered)
@@ -244,23 +244,23 @@ sequence starts at the numbers in use. Format per
**IP** on the first parsed datagram and sends replies to the **IP** on the first parsed datagram and sends replies to the
**latest-seen port** from that IP (the NAT port-rebind case keeps **latest-seen port** from that IP (the NAT port-rebind case keeps
receiving); datagrams from other source IPs are dropped (RFC 1928's receiving); datagrams from other source IPs are dropped (RFC 1928's
MUST, previously unimplemented — the spoof primitive shrank from MUST, previously unimplemented, so the spoof primitive shrank from
any-source to same-IP off-path). The **remaining residual**: a full any-source to same-IP off-path). The **remaining residual**: a full
client IP change mid-association (a NAT *address* rebind) stops client IP change mid-association (a NAT *address* rebind) stops
receiving replies — the RFC's expected-peer posture (the RFC pins receiving replies (the RFC's expected-peer posture: the RFC pins
the expected IP per association too). the expected IP per association too).
- **Option 3 (the complete fix, deferred)**: per-ASSOCIATE relay - **Option 3 (the complete fix, deferred)**: per-ASSOCIATE relay
sockets spawned by the TCP door, with the expected peer IP derived sockets spawned by the TCP door, with the expected peer IP derived
from the TCP control connection's remote address (the RFC's from the TCP control connection's remote address (the RFC's
"acquire from the SOCKS server" reading) — RFC-faithful end to end "acquire from the SOCKS server" reading): RFC-faithful end to end
and kills the same-IP spoof residual too. Deferred because it and kills the same-IP spoof residual too. Deferred because it
restructures the doors' client-correlation story (currently the restructures the doors' client-correlation story (currently the
loose assembly-layer wiring seam) and needs its own ADR — not loose assembly-layer wiring seam) and needs its own ADR; not
buildable speculatively without a deployment that actually spells buildable speculatively without a deployment that actually spells
the wildcard bind *and* suffers address-level rebinds. the wildcard bind *and* suffers address-level rebinds.
- **Blocked on**: a concrete wildcard-bind deployment with - **Blocked on**: a concrete wildcard-bind deployment with
address-level client mobility (QUIC connection migration is the address-level client mobility (QUIC connection migration is the
likely driver — the channels association survives transport likely driver: the channels association survives transport
migration while a pinned IP does not). migration while a pinned IP does not).
- **Cross-references**: review 003 S-1, ADR-003 (the handle swap), - **Cross-references**: review 003 S-1, ADR-003 (the handle swap),
ADR-014 (the doors own the accept-path defense), OQ-SK-03 ADR-014 (the doors own the accept-path defense), OQ-SK-03
@@ -271,11 +271,11 @@ sequence starts at the numbers in use. Format per
|----|--------|------------|--------------| |----|--------|------------|--------------|
| OQ-SK2-06 | deferred(scope) | a concrete sandboxed-adapter use case | `architecture/oq-sk2-06-wasm-adapter-story` | | OQ-SK2-06 | deferred(scope) | a concrete sandboxed-adapter use case | `architecture/oq-sk2-06-wasm-adapter-story` |
| OQ-SK2-07 | deferred(scope) | a concrete virtual-subnet deployment | `architecture/oq-sk2-07-virtual-subnet-helper` | | OQ-SK2-07 | deferred(scope) | a concrete virtual-subnet deployment | `architecture/oq-sk2-07-virtual-subnet-helper` |
| OQ-SK2-09 | partially resolved (option 3 deferred) | a wildcard-bind deployment with address-level client mobility | — (the posture is recorded here; a tracker task spawns with the deployment driver) | | OQ-SK2-09 | partially resolved (option 3 deferred) | a wildcard-bind deployment with address-level client mobility | none (the posture is recorded here; a tracker task spawns with the deployment driver) |
## References ## References
- Phase 0 ledger: `docs/research/phase-0.md` §Open Questions - Phase 0 ledger: `docs/research/phase-0.md` §Open Questions
(OQ-SK-01..09 — the full reasoning trails live there) (OQ-SK-01..09; the full reasoning trails live there)
- The SDD process: `docs/sdd_process.md` (the OQ format; the - The SDD process: `docs/sdd_process.md` (the OQ format; the
deferred-OQ blocker-task half) deferred-OQ blocker-task half)
+47 -47
View File
@@ -1,14 +1,14 @@
--- ---
status: draft status: draft
last_updated: 2026-09-28 (OQ-SK2-09 indexed — the UDP door's peer-pin last_updated: 2026-09-28 (OQ-SK2-09 indexed: the UDP door's peer-pin
posture; OQ-SK2-06's trigger ownership checked; ADR-014 indexed — posture; OQ-SK2-06's trigger ownership checked; ADR-014 indexed: the
the idle-timeout posture; the targeted fork — ADR-013, the module is idle-timeout posture; the targeted fork, ADR-013, the module is
`src/rfc1928/`; the vendor extraction amendment — no `net` feature — `src/rfc1928/`; the vendor extraction amendment, no `net` feature,
2026-09-21; Phase 1 initial draft 2026-09-20 — the OQ-SK ledger 2026-09-21; Phase 1 initial draft 2026-09-20; the OQ-SK ledger
promoted into ADRs 001-012 and open-questions.md) promoted into ADRs 001-012 and open-questions.md)
--- ---
# alksocks — Overview # alksocks: Overview
A SOCKS5 (RFC 1928) producer/consumer protocol crate on alkcall A SOCKS5 (RFC 1928) producer/consumer protocol crate on alkcall
channels: an arbitrary-egress proxy service that **never binds a port channels: an arbitrary-egress proxy service that **never binds a port
@@ -16,7 +16,7 @@ unless explicitly configured to**, carrying the RFC conversation
inside a channels data channel like any other produced resource. The inside a channels data channel like any other produced resource. The
sibling of alktty (`alk/tty`) and alktunnels (`alk/tunnel`) on the sibling of alktty (`alk/tty`) and alktunnels (`alk/tunnel`) on the
alkcall substrate; where alktunnels realizes the `-L`/`-R` forwarding alkcall substrate; where alktunnels realizes the `-L`/`-R` forwarding
flavors, alksocks realizes the `-D` flavor (dynamic / SOCKS5 proxy) — flavors, alksocks realizes the `-D` flavor (dynamic / SOCKS5 proxy),
the one alktunnels explicitly deferred to composition. This document the one alktunnels explicitly deferred to composition. This document
covers the crate's purpose, the resource model in brief, dependencies, covers the crate's purpose, the resource model in brief, dependencies,
the ALPN, feature gates, and the module map; component details are in the ALPN, feature gates, and the module map; component details are in
@@ -33,9 +33,9 @@ front doors:
`channels/socks5/sub` open op (registered via `channels/socks5/sub` open op (registered via
`ChannelCore::register_openable_with_establisher`, alkcall `ChannelCore::register_openable_with_establisher`, alkcall
ADR-047/049). The establisher is a *session gate* (validates the ADR-047/049). The establisher is a *session gate* (validates the
opener identity; the SOCKS5 target is unknown at open time); the opener identity; the SOCKS5 target is unknown at open time); the
open handler runs the RFC 1928 state machine in-stream over the open handler runs the RFC 1928 state machine in-stream over the
channel's `BiStream` — the forked RFC 1928 core channel's `BiStream`; the forked RFC 1928 core
([rfc1928.md](rfc1928.md)) drives negotiation, command read, and ([rfc1928.md](rfc1928.md)) drives negotiation, command read, and
replies; the dial/egress/accept seams (ADR-004) supply the I/O replies; the dial/egress/accept seams (ADR-004) supply the I/O
planes; `alkcall::channels::pump_bidi` (ADR-050) is the data plane planes; `alkcall::channels::pump_bidi` (ADR-050) is the data plane
@@ -46,14 +46,14 @@ front doors:
the associate session and the BIND reply#2 reader). *Wrapper-aware* the associate session and the BIND reply#2 reader). *Wrapper-aware*
means a client that knows the in-band conventions (the sentinel means a client that knows the in-band conventions (the sentinel
BND.ADDR, in-band frames) rather than a vanilla RFC 1928 client. BND.ADDR, in-band frames) rather than a vanilla RFC 1928 client.
Optionally it Optionally it
exposes the service locally: the **front door** — a real exposes the service locally through the **front door**, a real
`socks5://127.0.0.1:port` endpoint relaying each accepted `socks5://127.0.0.1:port` endpoint relaying each accepted
connection into a channel (the ssh `-D` local-exposure shape; the connection into a channel (the ssh `-D` local-exposure shape; the
path curl/browser/tun2proxy point at). path curl/browser/tun2proxy point at).
- **The optional producer local backend**: the producer may serve - **The optional producer local backend**: the producer may serve
from a real listener (feature-gated; the same door shape in the from a real listener (feature-gated; the same door shape in the
other direction — vanilla clients → a locally-served SOCKS5 other direction: vanilla clients → a locally-served SOCKS5
resource). resource).
The defining posture (the crate's reason to exist as a separate The defining posture (the crate's reason to exist as a separate
@@ -76,7 +76,7 @@ binding decision belongs to the caller.
SOCKS5 leg; alktunnels carries the tunnel; tun2proxy closes the SOCKS5 leg; alktunnels carries the tunnel; tun2proxy closes the
loop. loop.
- The substrate-agnostic protocol layer: the forked RFC 1928 core is - The substrate-agnostic protocol layer: the forked RFC 1928 core is
generic over `T: AsyncRead + AsyncWrite + Unpin` — a channels generic over `T: AsyncRead + AsyncWrite + Unpin`; a channels
`BiStream` drops in directly (POC #1), a `TcpStream` drops in at `BiStream` drops in directly (POC #1), a `TcpStream` drops in at
the local backends. Neither substrate leaks into the protocol the local backends. Neither substrate leaks into the protocol
layer (the alktty `TtyBackend` / alktunnels pump-halves layer (the alktty `TtyBackend` / alktunnels pump-halves
@@ -89,7 +89,7 @@ A SOCKS5 channel's life (full detail in [wire.md](wire.md) and
1. **Open (the establishment phase).** The consumer opens 1. **Open (the establishment phase).** The consumer opens
`channels/socks5/sub` with trivial params (ADR-001). The `channels/socks5/sub` with trivial params (ADR-001). The
establisher is the session gate — it checks/records the opener establisher is the session gate: it checks/records the opener
identity and does session-level policy; it never sees a target. identity and does session-level policy; it never sees a target.
Failure is a typed `channel:open_failed` (alkcall ADR-049); no Failure is a typed `channel:open_failed` (alkcall ADR-049); no
phantom channel. phantom channel.
@@ -99,8 +99,8 @@ A SOCKS5 channel's life (full detail in [wire.md](wire.md) and
dispatch: CONNECT/ASSOCIATE require `socks5:open`; BIND requires dispatch: CONNECT/ASSOCIATE require `socks5:open`; BIND requires
`socks5:bind` (the command gate, ADR-006). `socks5:bind` (the command gate, ADR-006).
3. **Data plane.** CONNECT: the dial seam (ADR-004) produces boxed 3. **Data plane.** CONNECT: the dial seam (ADR-004) produces boxed
halves; `pump_bidi` two-pumps channel ↔ target — raw halves; `pump_bidi` two-pumps channel ↔ target (raw
pass-through, 0 B overhead. ASSOCIATE: the reply's BND.ADDR is a pass-through, 0 B overhead). ASSOCIATE: the reply's BND.ADDR is a
pure RFC handle (the sentinel in-band); the datagram stage rides pure RFC handle (the sentinel in-band); the datagram stage rides
`[len: u16 BE][addr + data]` frames (ADR-003, the handle swap) to `[len: u16 BE][addr + data]` frames (ADR-003, the handle swap) to
the egress seam. BIND: reply#1 → quiet wait → exactly one accept the egress seam. BIND: reply#1 → quiet wait → exactly one accept
@@ -119,34 +119,34 @@ protocol layer; crate-level vocabulary is producer/consumer.
## Dependencies ## Dependencies
``` ```
alksocks (default — wasm-clean) alksocks (default: wasm-clean)
├── alkcall 0.10.1 (core types: Connection, ProtocolHandler, BiStream, ├── alkcall 0.10.1 (core types: Connection, ProtocolHandler, BiStream,
│ AuthContext, Identity, AccessControl, OwnershipProvider; │ AuthContext, Identity, AccessControl, OwnershipProvider;
│ channels: ChannelCore/ChannelClient, │ channels: ChannelCore/ChannelClient,
│ register_openable_with_establisher, pump_bidi, │ register_openable_with_establisher, pump_bidi,
│ Establishment/ChannelPlan + reply projection, │ Establishment/ChannelPlan + reply projection,
│ ServingConfig identity seam — CF-005/006) │ the ServingConfig identity seam (CF-005/006))
├── tokio (wasm-clean subset: rt, sync, io-util, macros, time) ├── tokio (wasm-clean subset: rt, sync, io-util, macros, time)
├── serde/serde_json (params schema), thiserror (error surface), tracing ├── serde/serde_json (params schema), thiserror (error surface), tracing
└── (no backend deps — sockets live in feature-gated modules) └── (no backend deps: sockets live in feature-gated modules)
alksocks (local feature) — non-wasm by design alksocks (local feature): non-wasm by design
└── adds: tokio/net + socket2 (the feature's own socket providers) and └── adds: tokio/net + socket2 (the feature's own socket providers) and
the first-party local backends + front doors the first-party local backends + front doors
alksocks (composed feature) alksocks (composed feature)
└── adds: alktunnels 0.4.1 (the direct op egress; the bind-first └── adds: alktunnels 0.4.1 (the direct op egress; the bind-first
listen accept; ADR-008's composed providers) — its default listen accept; ADR-008's composed providers), whose default
crate is wasm-clean; the composed providers use only the crate is wasm-clean; the composed providers use only the
ungated channels surface (verify with the wasm check) ungated channels surface (verify with the wasm check)
``` ```
Same posture as alktty/alktunnels: the default crate is Same posture as alktty/alktunnels: the default crate is
protocol-only and wasm-clean — the RFC 1928 core is an *extraction* protocol-only and wasm-clean; the RFC 1928 core is an *extraction*
of fast-socks5's `T`-generic subset, containing zero kernel-net code, of fast-socks5's `T`-generic subset, containing zero kernel-net code,
so there is no `net` feature at all (ADR-011 §Amendment); so there is no `net` feature at all (ADR-011 §Amendment);
substrate I/O rides `local` alone (ADR-009 §Amendment). substrate I/O rides `local` alone (ADR-009 §Amendment).
`fast-socks5` is NOT a Cargo dependency — it was extracted in-tree `fast-socks5` is NOT a Cargo dependency: it was extracted in-tree
and forked (ADR-013); `/workspace/fast-socks5` stays as the and forked (ADR-013); `/workspace/fast-socks5` stays as the
differential-testing behavioral reference. differential-testing behavioral reference.
@@ -154,44 +154,44 @@ differential-testing behavioral reference.
| Feature | Contents | Wasm | | Feature | Contents | Wasm |
|---------|----------|------| |---------|----------|------|
| *(default)* | the RFC 1928 core (`src/rfc1928/`, ADR-013 — codecs, typestate), wire codec, open-op spec, the three seams' traits/types, producer + consumer session types — protocol only; zero kernel-net code, wasm-clean by construction | yes | | *(default)* | the RFC 1928 core (`src/rfc1928/`, ADR-013: codecs, typestate), wire codec, open-op spec, the three seams' traits/types, producer + consumer session types; protocol only, zero kernel-net code, wasm-clean by construction | yes |
| `local` | the crate's first-party local backends: TCP dial, UDP egress socket, BIND `TcpListener` accept, the front doors (consumer + producer local shapes); brings `tokio/net` + `socket2` into the graph | no (build error on wasm, the alktty posture) | | `local` | the crate's first-party local backends: TCP dial, UDP egress socket, BIND `TcpListener` accept, the front doors (consumer + producer local shapes); brings `tokio/net` + `socket2` into the graph | no (build error on wasm, the alktty posture) |
| `composed` | the composed providers over alktunnels 0.4.1: direct-op egress (lazy destination→tunnel table), bind-first listen accept — adds the alktunnels dep | yes (alktunnels' default crate is wasm-clean; the composed providers use only its ungated channels surface — verify with the wasm check at implementation) | | `composed` | the composed providers over alktunnels 0.4.1: direct-op egress (lazy destination→tunnel table), bind-first listen accept; adds the alktunnels dep | yes (alktunnels' default crate is wasm-clean; the composed providers use only its ungated channels surface: verify with the wasm check at implementation) |
## Module Map ## Module Map
``` ```
src/ src/
lib.rs — re-exports; crate docs lib.rs : re-exports; crate docs
rfc1928/ — the RFC 1928 core: the targeted fork of rfc1928/ : the RFC 1928 core: the targeted fork of
fast-socks5's T-generic subset (ADR-013; fast-socks5's T-generic subset (ADR-013;
extraction per ADR-011; fork point noted extraction per ADR-011; fork point noted
in-tree): consts, codecs, errors, typestate, in-tree): consts, codecs, errors, typestate,
client use_stream path; the fork's two client use_stream path; the fork's two
carried changes (BIND reply surface, carried changes (BIND reply surface,
new_reply publication); no kernel-net code new_reply publication); no kernel-net code
params.rs — the trivial params schema + SOCKS5_OPEN_SCOPE/ params.rs : the trivial params schema + SOCKS5_OPEN_SCOPE/
SOCKS5_BIND_SCOPE constants (ADR-001/006) SOCKS5_BIND_SCOPE constants (ADR-001/006)
wire.rs — the ASSOCIATE datagram frame codec, re-implemented wire.rs : the ASSOCIATE datagram frame codec, re-implemented
in first-party code shaped on alktunnels' length in first-party code shaped on alktunnels' length
codec (ADR-003; framing is wire-layer, applied codec (ADR-003; framing is wire-layer, applied
exactly once) exactly once)
producer.rs — the open op spec (scope-gated), the session-gate producer.rs : the open op spec (scope-gated), the session-gate
establisher, the command-dispatch pump handler establisher, the command-dispatch pump handler
(typestate driving + the three seams), teardown (typestate driving + the three seams), teardown
consumer.rs — the channels-native session types (connect/associate/ consumer.rs : the channels-native session types (connect/associate/
bind reply#2 reader), teardown ownership bind reply#2 reader), teardown ownership
door.rs — the front doors (feature `local`): the raw-pipe door.rs : the front doors (feature `local`): the raw-pipe
bridge + the RFC 1929 cred-bearing door + the bridge + the RFC 1929 cred-bearing door + the
credential→identity mapping (ADR-005); the UDP credential→identity mapping (ADR-005); the UDP
front door's RFC codec boundary (ADR-003) front door's RFC codec boundary (ADR-003)
egress.rs — the egress/accept seam types (DialFn/EgressFactory/ egress.rs : the egress/accept seam types (DialFn/EgressFactory/
BindAcceptFactory contracts — ADR-004) BindAcceptFactory contracts, ADR-004)
local/ — feature `local`: the socket-backed provider local/ : feature `local`: the socket-backed provider
constructors (dial/egress/accept) constructors (dial/egress/accept)
composed/ — feature `composed`: the alktunnels-composed composed/ : feature `composed`: the alktunnels-composed
providers (direct-op egress, bind-first accept) providers (direct-op egress, bind-first accept)
error.rs — SocksError + the typed open-error surface error.rs : SocksError + the typed open-error surface
(alkcall ADR-049 §4) (alkcall ADR-049 §4)
``` ```
@@ -205,7 +205,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary | | ADR | Decision | Summary |
|-----|----------|---------| |-----|----------|---------|
| [001](decisions/001-alpn-and-open-op.md) | ALPN + open op + trivial params | `alk/socks5`, `channels/socks5/sub`, `{}` — the RFC conversation is the data plane | | [001](decisions/001-alpn-and-open-op.md) | ALPN + open op + trivial params | `alk/socks5`, `channels/socks5/sub`, `{}`: the RFC conversation is the data plane |
| [002](decisions/002-protocol-base.md) | Protocol base | fast-socks5's explicit typestate API (extracted + forked, `src/rfc1928/`); faithful reply-code mapping | | [002](decisions/002-protocol-base.md) | Protocol base | fast-socks5's explicit typestate API (extracted + forked, `src/rfc1928/`); faithful reply-code mapping |
| [003](decisions/003-udp-handle-swap.md) | The handle swap | one channel per association; `{addr, data}` frames in-band; RFC codec at front doors only | | [003](decisions/003-udp-handle-swap.md) | The handle swap | one channel per association; `{addr, data}` frames in-band; RFC codec at front doors only |
| [004](decisions/004-dial-callback-shape.md) | The dial-callback shape | function-not-trait at three seams (dial/egress/accept); ownership rides the handle | | [004](decisions/004-dial-callback-shape.md) | The dial-callback shape | function-not-trait at three seams (dial/egress/accept); ownership rides the handle |
@@ -213,7 +213,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [006](decisions/006-access-control.md) | Access-control posture | `socks5:open` gate + `socks5:bind` command gate; target policy = the dialer | | [006](decisions/006-access-control.md) | Access-control posture | `socks5:open` gate + `socks5:bind` command gate; target policy = the dialer |
| [007](decisions/007-bind-in-base.md) | BIND in the base crate | phase model extended (reply#1 → quiet wait → accept → reply#2 → pass-through); additive | | [007](decisions/007-bind-in-base.md) | BIND in the base crate | phase model extended (reply#1 → quiet wait → accept → reply#2 → pass-through); additive |
| [008](decisions/008-provider-defaults.md) | Provider defaults | local-socket + composed (direct op) providers, both feature-gated; no implicit dialer | | [008](decisions/008-provider-defaults.md) | Provider defaults | local-socket + composed (direct op) providers, both feature-gated; no implicit dialer |
| [009](decisions/009-feature-gates.md) | Feature gates | `local` + `composed`; no `net` feature (the RFC 1928 core is wasm-clean by construction — ADR-011 §Amendment); default crate wasm-clean | | [009](decisions/009-feature-gates.md) | Feature gates | `local` + `composed`; no `net` feature (the RFC 1928 core is wasm-clean by construction; ADR-011 §Amendment); default crate wasm-clean |
| [010](decisions/010-channel-capacity.md) | Channel capacity | no wire change; assembly-layer config + the documented proxy-workload recommendation | | [010](decisions/010-channel-capacity.md) | Channel capacity | no wire change; assembly-layer config + the documented proxy-workload recommendation |
| [011](decisions/011-inline-vendor.md) | The extraction | the `T`-generic subset copied in-tree; two carried changes (BIND, `new_reply`); v1.0.0 noted (superseded as a posture by ADR-013) | | [011](decisions/011-inline-vendor.md) | The extraction | the `T`-generic subset copied in-tree; two carried changes (BIND, `new_reply`); v1.0.0 noted (superseded as a posture by ADR-013) |
| [013](decisions/013-targeted-fork.md) | The targeted fork | the extracted region is owned; `src/rfc1928/`; fork point `5b18d4d`; hand-port replaces re-vendor | | [013](decisions/013-targeted-fork.md) | The targeted fork | the extracted region is owned; `src/rfc1928/`; fork point `5b18d4d`; hand-port replaces re-vendor |
@@ -225,13 +225,13 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
Open questions are tracked in [open-questions.md](open-questions.md). Open questions are tracked in [open-questions.md](open-questions.md).
Key questions affecting this document: Key questions affecting this document:
- **OQ-SK2-06**: sandboxed-wasm-consumer adapter story (deferred(scope) - **OQ-SK2-06**: sandboxed-wasm-consumer adapter story (deferred(scope);
— a product input, nothing waits on it; the trigger is external by a product input, nothing waits on it; the trigger is external by
verified default) verified default)
- **OQ-SK2-08**: recommended proxy-workload cap figure (open — a docs - **OQ-SK2-08**: recommended proxy-workload cap figure (open, a docs
tuning note; ADR-010 pins the posture) tuning note; ADR-010 pins the posture)
- **OQ-SK2-09**: the UDP door's peer-pin posture (option 2 — the - **OQ-SK2-09**: the UDP door's peer-pin posture (option 2, the
same-IP lock + reply-port refresh — landed in 0.1.0; option 3, the same-IP lock + reply-port refresh, landed in 0.1.0; option 3, the
control-conn-derived expected peer, deferred on a concrete control-conn-derived expected peer, deferred on a concrete
wildcard-bind deployment) wildcard-bind deployment)
+37 -37
View File
@@ -1,10 +1,10 @@
--- ---
status: draft status: draft
last_updated: 2026-09-24 (the open handler's phase lifetimes carry the last_updated: 2026-09-24 (the open handler's phase lifetimes carry the
decided timeout posture — ADR-014; Phase 1 initial draft 2026-09-20) decided timeout posture, ADR-014; Phase 1 initial draft 2026-09-20)
--- ---
# alksocks — The Producer Half # alksocks: The Producer Half
The producer exposes the SOCKS5 service as a produced, ACL-scoped The producer exposes the SOCKS5 service as a produced, ACL-scoped
resource: it registers the `channels/socks5/sub` open op (ADR-001) resource: it registers the `channels/socks5/sub` open op (ADR-001)
@@ -22,33 +22,33 @@ Registration uses `ChannelCore::register_openable_with_establisher`
- the op spec `channels/socks5/sub` on the `alk/socks5` ALPN, - the op spec `channels/socks5/sub` on the `alk/socks5` ALPN,
`required_scopes = ["socks5:open"]` (`SOCKS5_OPEN_SCOPE`, ADR-006); `required_scopes = ["socks5:open"]` (`SOCKS5_OPEN_SCOPE`, ADR-006);
- params schema `{type: object}`, `additionalProperties: false` - params schema `{type: object}`, `additionalProperties: false`
(trivial — ADR-001); (trivial, ADR-001);
- the establisher: validates/records the opener identity (the CF-005/ - the establisher: validates/records the opener identity (the CF-005/
CF-006 seam — ADR-005) and applies session-level policy. It runs CF-006 seam, ADR-005) and applies session-level policy. It runs
`Establishment::default()`-shaped (no plan payload) — the dial `Establishment::default()`-shaped (no plan payload): the dial
happens post-establishment, so there is nothing to thread happens post-establishment, so there is nothing to thread
establisher→handler (POC #1's wire note). establisher→handler (POC #1's wire note).
Failure semantics: a scope-less identity is `FORBIDDEN` at the Failure semantics: a scope-less identity is `FORBIDDEN` at the
registry ACL gate; establisher refusal is a typed registry ACL gate; establisher refusal is a typed
`channel:open_failed` (`handler_error`-class or the mapped reason) — `channel:open_failed` (`handler_error`-class or the mapped reason),
never a phantom channel. Session-level refusal is channels-level; never a phantom channel. Session-level refusal is channels-level;
target-level refusal is in-stream RFC (below). target-level refusal is in-stream RFC (below).
**Harness wiring requirement** (POC #1, POC #2 re-witnessed): the **Harness wiring requirement** (POC #1, POC #2 re-witnessed): the
accept-side install hook must call `channel0_conn.set_identity accept-side install hook must call `channel0_conn.set_identity
(resolved)` before building the `CallConnection` — the transport (resolved)` before building the `CallConnection`; the transport
identity does not flow to channel-0 dispatch automatically. A real identity does not flow to channel-0 dispatch automatically. A real
mTLS transport resolves the peer here. Without it, scope-gated opens mTLS transport resolves the peer here. Without it, scope-gated opens
fail closed with `FORBIDDEN "authentication required"` — correct fail closed with `FORBIDDEN "authentication required"`: correct
behavior, but the wiring requirement is implicit. behavior, but the wiring requirement is implicit.
## The Open Handler: RFC Conversation + Command Dispatch ## The Open Handler: RFC Conversation + Command Dispatch
The open handler runs the forked typestate over the channel's The open handler runs the forked typestate over the channel's
`BiStream` (`T`-generic — the POC #1 fit). Every phase is `BiStream` (`T`-generic, the POC #1 fit). Every phase is
teardown-governed, never idle-expired (ADR-014): the handler parks on teardown-governed, never idle-expired (ADR-014): the handler parks on
the handshake / stage reads like any other phase — no protocol-layer the handshake / stage reads like any other phase; no protocol-layer
inactivity timer exists (the alkcall 10s establishment bound covers inactivity timer exists (the alkcall 10s establishment bound covers
only the open-op round trip, ADR-049 §2); a silent peer holds the only the open-op round trip, ADR-049 §2); a silent peer holds the
task + channel entry until the channels-level sentinels arrive, and task + channel entry until the channels-level sentinels arrive, and
@@ -57,28 +57,28 @@ wants an idle bound enforces it outside the protocol layer (an
accept-path bound at a door; a caller-side timeout around a accept-path bound at a door; a caller-side timeout around a
substrate-free `serve_socks5_over`): substrate-free `serve_socks5_over`):
1. **Negotiation**: `start(inner)` → `negotiate_auth(&methods)` — 1. **Negotiation**: `start(inner)` → `negotiate_auth(&methods)`;
NoAuth as a no-op (ADR-005: the producer side is identity-only; NoAuth as a no-op (ADR-005: the producer side is identity-only;
authorization already happened at the open gate). authorization already happened at the open gate).
2. **Command read**: `read_command()` returns `(cmd, target)` — 2. **Command read**: `read_command()` returns `(cmd, target)`, the
the dispatch point: dispatch point:
- **CONNECT** → the command gate (`socks5:open` already checked - **CONNECT** → the command gate (`socks5:open` already checked
at the open gate — no extra check) → invoke `DialFn(target)` at the open gate, no extra check) → invoke `DialFn(target)`
(ADR-004). Success: `reply_success` then the pass-through data (ADR-004). Success: `reply_success` then the pass-through data
plane. Refusal: `reply_error` with the mapped RFC code plane. Refusal: `reply_error` with the mapped RFC code
(dial-failure → `ConnectionRefused` 0x05; scope-class refusal (dial-failure → `ConnectionRefused` 0x05; scope-class refusal
→ `ConnectionNotAllowed` 0x02) — no phantom data plane. → `ConnectionNotAllowed` 0x02); no phantom data plane.
- **TCP BIND** → **the command gate**: require `socks5:bind` - **TCP BIND** → **the command gate**: require `socks5:bind`
from the per-call opener identity (ADR-006) — an identity from the per-call opener identity (ADR-006); an identity
without it gets `ConnectionNotAllowed` (0x02) while CONNECT without it gets `ConnectionNotAllowed` (0x02) while CONNECT
still serves the same identity (POC #5 test 4). Then the BIND still serves the same identity (POC #5 test 4). Then the BIND
shape (ADR-007, below). shape (ADR-007, below).
- **UDP ASSOCIATE** → `reply_success(sentinel)` (the - **UDP ASSOCIATE** → `reply_success(sentinel)` (the
RFC-compatibility handle, ADR-003) then the datagram stage. RFC-compatibility handle, ADR-003) then the datagram stage.
3. **Data plane** per command (wire.md §The Data Plane): 3. **Data plane** per command (wire.md §The Data Plane):
- CONNECT: `pump_bidi` inline inside the handler task (the - CONNECT: `pump_bidi` inline inside the handler task (the
returned `JoinHandle` tracks the data-plane lifetime — R-02, returned `JoinHandle` tracks the data-plane lifetime: R-02,
alkcall review 007, alkcall review 007,
`/workspace/@alkdev/alkcall/docs/reviews/ `/workspace/@alkdev/alkcall/docs/reviews/
007-establishment-follow-ups-review.md`; early return = 007-establishment-follow-ups-review.md`; early return =
teardown-at-birth). teardown-at-birth).
@@ -101,7 +101,7 @@ substrate-free `serve_socks5_over`):
Contracts (POC-pinned, load-bearing): Contracts (POC-pinned, load-bearing):
- **Ownership rides the handle structurally** — handle payloads own - **Ownership rides the handle structurally**: handle payloads own
their channel sessions/sockets; the compiler should enforce it (no their channel sessions/sockets; the compiler should enforce it (no
`_` bindings of owning values; the third teardown-at-birth `_` bindings of owning values; the third teardown-at-birth
witness). witness).
@@ -109,21 +109,21 @@ Contracts (POC-pinned, load-bearing):
frames once. frames once.
- **The accept future owns its resources end-to-end**; the quiet - **The accept future owns its resources end-to-end**; the quiet
wait's `select!` drop IS the client-gone teardown. wait's `select!` drop IS the client-gone teardown.
- **No trait** — the re-evaluation threshold is three real - **No trait**: the re-evaluation threshold is three real
implementations as trait impls (the alktunnels ADR-004 posture). implementations as trait impls (the alktunnels ADR-004 posture).
## Scope Gating (ADR-006) ## Scope Gating (ADR-006)
- `SOCKS5_OPEN_SCOPE = "socks5:open"` — the open op's - `SOCKS5_OPEN_SCOPE = "socks5:open"`: the open op's
`required_scopes`; egress capability (CONNECT + ASSOCIATE). `required_scopes`; egress capability (CONNECT + ASSOCIATE).
- `SOCKS5_BIND_SCOPE = "socks5:bind"` — the BIND command gate, - `SOCKS5_BIND_SCOPE = "socks5:bind"`: the BIND command gate,
checked at command dispatch from the per-call opener identity; checked at command dispatch from the per-call opener identity;
ingress capability, never implied by `socks5:open`. ingress capability, never implied by `socks5:open`.
- Target policy: the dialer/egress/accept callbacks refuse (RFC - Target policy: the dialer/egress/accept callbacks refuse (RFC
reply codes); no allowlist hook in v1 (ADR-006/ADR-012). Whatever reply codes); no allowlist hook in v1 (ADR-006/ADR-012). Whatever
ACL governs the socks5 resource governs everything reachable ACL governs the socks5 resource governs everything reachable
through it, plus the callbacks' policy. through it, plus the callbacks' policy.
- Optional: consult `OwnershipProvider` for resource ownership — a - Optional: consult `OwnershipProvider` for resource ownership, a
registration-time choice. registration-time choice.
## Producer-Side Local Backend (feature `local`, optional) ## Producer-Side Local Backend (feature `local`, optional)
@@ -131,40 +131,40 @@ Contracts (POC-pinned, load-bearing):
The optional shape where the producer serves from a real listener: The optional shape where the producer serves from a real listener:
a feature-gated backend binds `socks5://127.0.0.1:port` (or any a feature-gated backend binds `socks5://127.0.0.1:port` (or any
address) and hands each accepted TCP connection to the landed address) and hands each accepted TCP connection to the landed
substrate-free entry `Socks5Producer::serve_socks5_over` directly — substrate-free entry `Socks5Producer::serve_socks5_over` directly:
the RFC conversation runs on the accepted socket (the NoAuth the RFC conversation runs on the accepted socket (the NoAuth
in-stream shape: greeting, command, reply, data plane, all on the in-stream shape: greeting, command, reply, data plane, all on the
accepted socket), and the seams dial/egress/accept locally. The same accepted socket), and the seams dial/egress/accept locally. The same
door shape in reverse: this is a *front door for the producer side* door shape in reverse: this is a *front door for the producer side*;
— vanilla clients reach a locally-served SOCKS5 resource through it vanilla clients reach a locally-served SOCKS5 resource through it
(`src/local/producer_door.rs`, the `ProducerDoor`/builder/guard (`src/local/producer_door.rs`, the `ProducerDoor`/builder/guard
surface). A cred-bearing variant (RFC 1929) would wrap surface). A cred-bearing variant (RFC 1929) would wrap
`accept_password_auth` before `serve_socks5_over` — a documented `accept_password_auth` before `serve_socks5_over`, a documented
follow-up, not landed. follow-up, not landed.
The door's accept path owns the idle defense the protocol layer does The door's accept path owns the idle defense the protocol layer does
not provide (ADR-014): an accepted connection that sends no first byte not provide (ADR-014): an accepted connection that sends no first byte
within a configured window is closed door-side (an accepted socket is within a configured window is closed door-side (an accepted socket is
not a channel — the cap regime does not bound it), plus the not a channel; the cap regime does not bound it), plus the
max-concurrent-sessions cap (an accept beyond the bound is refused max-concurrent-sessions cap (an accept beyond the bound is refused
door-side, no session spawned) — the two constructor knobs. door-side, no session spawned); these are the two constructor knobs.
The producer-side local *backend* for the I/O planes (the The producer-side local *backend* for the I/O planes (the
local-socket providers — TCP dial, UDP egress, BIND listener) is local-socket providers: TCP dial, UDP egress, BIND listener) is
ADR-008's variant set, also feature `local`. The BIND command gate ADR-008's variant set, also feature `local`. The BIND command gate
on this path is the builder's `bind_policy` (there is no open-op on this path is the builder's `bind_policy` (there is no open-op
identity on an accepted socket — the accepted socket is the whole identity on an accepted socket; the accepted socket is the whole
caller; the pump handler's identity-derived gate does not apply). caller, and the pump handler's identity-derived gate does not apply).
## Wiring (Assembly Layer) ## Wiring (Assembly Layer)
```rust ```rust
let producer = Socks5Producer::builder() let producer = Socks5Producer::builder()
.dial(dial_fn) // DialFn — required for CONNECT .dial(dial_fn) // DialFn: required for CONNECT
.egress(egress_factory) // EgressFactory — required for ASSOCIATE .egress(egress_factory) // EgressFactory: required for ASSOCIATE
.accept(bind_accept_factory) // BindAcceptFactory — required for BIND .accept(bind_accept_factory) // BindAcceptFactory: required for BIND
.build(); .build();
// register(&core, &registry, auth, witness) — the establisher rides // register(&core, &registry, auth, witness): the establisher rides
// the registration (the optional witness is the CF-006 test probe). // the registration (the optional witness is the CF-006 test probe).
producer.register(&core, &registry, auth, None)?; producer.register(&core, &registry, auth, None)?;
``` ```
+44 -44
View File
@@ -1,22 +1,22 @@
--- ---
status: draft status: draft
last_updated: 2026-09-24 (the keep-or-cut sweep + the refusal last_updated: 2026-09-24 (the keep-or-cut sweep + the refusal
vocabulary variant — the fork's owned post-fork changes section; vocabulary variant: the fork's owned post-fork changes section;
review 002 N-1/N-4; the targeted fork 2026-09-22 — ADR-013 review 002 N-1/N-4; the targeted fork 2026-09-22, with ADR-013
supersedes ADR-011's maintenance posture; the module is superseding ADR-011's maintenance posture; the module is
`src/rfc1928/`, the fork point is `5b18d4d`, the lint-allow posture `src/rfc1928/`, the fork point is `5b18d4d`, the lint-allow posture
dissolves; extraction review F-1: `ReplyError::from_u8`'s panic arm dissolves; extraction review F-1: `ReplyError::from_u8`'s panic arm
mapped to `GeneralFailure`; Phase 1 initial draft 2026-09-20) mapped to `GeneralFailure`; Phase 1 initial draft 2026-09-20)
--- ---
# alksocks — The RFC 1928 Core (the targeted fork) # alksocks: The RFC 1928 Core (the targeted fork)
The crate owns its RFC 1928 protocol code in-tree: the **targeted The crate owns its RFC 1928 protocol code in-tree: the **targeted
fork** of fast-socks5's `T`-generic subset ([ADR-013](decisions/ fork** of fast-socks5's `T`-generic subset ([ADR-013](decisions/
013-targeted-fork.md); the extraction mechanics in 013-targeted-fork.md); the extraction mechanics in
[ADR-011](decisions/011-inline-vendor.md); OQ-SK-04's resolution). [ADR-011](decisions/011-inline-vendor.md); OQ-SK-04's resolution).
This document is the fork's scope — what was extracted, what was This document is the fork's scope (what was extracted, what was
not, the deliberate modifications — and the maintenance posture. not, the deliberate modifications) and the maintenance posture.
The protocol base decision is ADR-002; the feature gating is The protocol base decision is ADR-002; the feature gating is
ADR-009. ADR-009.
@@ -30,25 +30,25 @@ review-vendor-extraction.md`) is the recorded fidelity gate.
The code arrived by **extraction** (ADR-011 §Amendment): copy *only* The code arrived by **extraction** (ADR-011 §Amendment): copy *only*
the `T`-generic inventory from fast-socks5 v1.0.0 into the tree; the the `T`-generic inventory from fast-socks5 v1.0.0 into the tree; the
kernel-net handlers are **not copied** — they stay in kernel-net handlers are **not copied**; they stay in
`/workspace/fast-socks5` (the read-only reference checkout). `/workspace/fast-socks5` (the read-only reference checkout).
Concretely: POC #4's cfg map (its five-file inventory of what is Concretely: POC #4's cfg map (its five-file inventory of what is
kernel-net-gated) is the base of the extraction recipe — copy what kernel-net-gated) is the base of the extraction recipe: copy what
POC #4 left *ungated*, minus the two recorded exclusions (the POC #4 left *ungated*, minus the two recorded exclusions (the
`socks4` module and the legacy `Config`/`Authentication` layer — `socks4` module and the legacy `Config`/`Authentication` layer; see
see the not-forked inventory). The extraction contains zero the not-forked inventory). The extraction contains zero
kernel-net code, so it is wasm-clean by construction; there is no kernel-net code, so it is wasm-clean by construction; there is no
in-tree `net`-shaped feature gate. in-tree `net`-shaped feature gate.
Post-extraction (ADR-013), the region is **owned code**: project Post-extraction (ADR-013), the region is **owned code**: project
conventions apply unconditionally, there is no upstream tree to conventions apply unconditionally, there is no upstream tree to
stay reconcilable with, and the module is `src/rfc1928/` — named stay reconcilable with, and the module is `src/rfc1928/`, named
for what it is (the RFC 1928 wire vocabulary and state machines), for what it is (the RFC 1928 wire vocabulary and state machines),
not for an ownership relation it no longer has. not for an ownership relation it no longer has.
## What was Extracted (now the forked core) ## What was Extracted (now the forked core)
From fast-socks5 **v1.0.0** (MIT, third-party upstream — From fast-socks5 **v1.0.0** (MIT, third-party upstream;
`/workspace/fast-socks5` is the read-only reference checkout; the `/workspace/fast-socks5` is the read-only reference checkout; the
fork point is noted in the module docs and the license notice): fork point is noted in the module docs and the license notice):
@@ -58,34 +58,34 @@ fork point is noted in the module docs and the license notice):
| The auth traits (`AuthMethod<T>`/`AuthMethodSuccessState<T>`) | the negotiation machinery (the crate uses `accept_no_auth` in-band; the traits stay) | `T`-generic | | The auth traits (`AuthMethod<T>`/`AuthMethodSuccessState<T>`) | the negotiation machinery (the crate uses `accept_no_auth` in-band; the traits stay) | `T`-generic |
| `transfer` + `wait_on_tcp` | the typestate's own two-pump seam (the crate's data plane is `pump_bidi`; `transfer` is the interception-point shape) | `T`-generic | | `transfer` + `wait_on_tcp` | the typestate's own two-pump seam (the crate's data plane is `pump_bidi`; `transfer` is the interception-point shape) | `T`-generic |
| The codecs: `new_udp_header`/`parse_udp_request`, `TargetAddr`/`to_be_bytes`/`read_address`, `ReplyError`/`SocksError`/`SocksServerError` | the RFC wire vocabulary everywhere (front doors, associate frames, reply#2 reader) | pure; `new_reply` published in-tree (fork change) | | The codecs: `new_udp_header`/`parse_udp_request`, `TargetAddr`/`to_be_bytes`/`read_address`, `ReplyError`/`SocksError`/`SocksServerError` | the RFC wire vocabulary everywhere (front doors, associate frames, reply#2 reader) | pure; `new_reply` published in-tree (fork change) |
| The client types: `Socks5Stream<S>::use_stream` + the `AsyncRead/AsyncWrite` impls | the consumer half's stream session (ADR-002; POC #1's fit) — the first-party associate session in `consumer.rs` was informed by this shape | `T`-generic | | The client types: `Socks5Stream<S>::use_stream` + the `AsyncRead/AsyncWrite` impls | the consumer half's stream session (ADR-002; POC #1's fit); the first-party associate session in `consumer.rs` was informed by this shape | `T`-generic |
| The non-net test modules (where extractable as unit tests) | RFC edge-case coverage rides in-tree | adjusted to the crate's test layout | | The non-net test modules (where extractable as unit tests) | RFC edge-case coverage rides in-tree | adjusted to the crate's test layout |
## What was NOT Forked (the cut, re-read as the hand-port map) ## What was NOT Forked (the cut, re-read as the hand-port map)
These stay in `/workspace/fast-socks5` (the differential-testing These stay in `/workspace/fast-socks5` (the differential-testing
reference); the table below records the boundary — under the fork reference); the table below records the boundary; under the fork
posture it is the **hand-port map**: what to port from the posture it is the **hand-port map**: what to port from the
reference checkout if a future need surfaces (port it, adapt it to reference checkout if a future need surfaces (port it, adapt it to
the crate's conventions, let the differential suite guard it — the crate's conventions, let the differential suite guard it;
ADR-013). Rows 1–5 are POC #4's five-file cfg map (what POC #4 ADR-013). Rows 1–5 are POC #4's five-file cfg map (what POC #4
gated is what was not extracted); the socks4 row and the legacy gated is what was not extracted); the socks4 row and the legacy
`Config`/`Authentication` layer are the recipe's two additions — `Config`/`Authentication` layer are the recipe's two additions:
the socks4 module is out of RFC 1928 scope, and the legacy auth the socks4 module is out of RFC 1928 scope, and the legacy auth
layer was left ungated by POC #4 (it compiles without net) but no layer was left ungated by POC #4 (it compiles without net) but no
extraction consumer reached it. None of it is forked: extraction consumer reached it. None of it is forked:
| Upstream file | Region NOT in `src/rfc1928/` | | Upstream file | Region NOT in `src/rfc1928/` |
|---|---| |---|---|
| `src/server.rs` | `run_tcp_proxy`, `run_udp_proxy`/`run_udp_proxy_custom`, `udp_bind_random_port`, the legacy `Socks5Server`/`Incoming`/`Socks5Socket` (struct + impls), `DnsResolveHelper` trait + impl, `handle_udp_*`/`transfer_udp`, the net-only test module — plus the legacy `Config<A>` + `Authentication` trait + impls (`SimpleUserPassword`/`DenyAuthentication`/`AcceptAuthentication`, `authenticate_callback`), which POC #4 left ungated but which only this cut region consumes (rows 1–5 are POC #4's gate map; the `Config`/`Authentication` layer and the socks4 row are this recipe's two additions) | | `src/server.rs` | `run_tcp_proxy`, `run_udp_proxy`/`run_udp_proxy_custom`, `udp_bind_random_port`, the legacy `Socks5Server`/`Incoming`/`Socks5Socket` (struct + impls), `DnsResolveHelper` trait + impl, `handle_udp_*`/`transfer_udp`, the net-only test module; plus the legacy `Config<A>` + `Authentication` trait + impls (`SimpleUserPassword`/`DenyAuthentication`/`AcceptAuthentication`, `authenticate_callback`), which POC #4 left ungated but which only this cut region consumes (rows 1–5 are POC #4's gate map; the `Config`/`Authentication` layer and the socks4 row are this recipe's two additions) |
| `src/client.rs` | `Socks5Datagram` (struct + impl), the `Socks5Stream<TcpStream>` convenience impl (`connect`/`connect_with_password`/`connect_raw`) | | `src/client.rs` | `Socks5Datagram` (struct + impl), the `Socks5Stream<TcpStream>` convenience impl (`connect`/`connect_with_password`/`connect_raw`) |
| `src/util/stream.rs` | `tcp_connect*` fns (the `read_exact!`/`ready!` macros + `ConnectError` stay — `ConnectError` kept-with-note, review 001 C-1: constructed nowhere in-tree, kept as the hand-port map's companion for a future kernel-net port) | | `src/util/stream.rs` | `tcp_connect*` fns (the `read_exact!`/`ready!` macros + `ConnectError` stay; `ConnectError` kept-with-note, review 001 C-1: constructed nowhere in-tree, kept as the hand-port map's companion for a future kernel-net port) |
| `src/util/target_addr.rs` | the `resolve_dns` method (the codec stays) | | `src/util/target_addr.rs` | the `resolve_dns` method (the codec stays) |
| `src/lib.rs` | the net-heavy test module (consts/errors/UDP-header codec stay) | | `src/lib.rs` | the net-heavy test module (consts/errors/UDP-header codec stay) |
| `src/socks4/` | the whole module (upstream's separate default-off `socks4` cargo feature; out of RFC 1928 scope) | | `src/socks4/` | the whole module (upstream's separate default-off `socks4` cargo feature; out of RFC 1928 scope) |
**Why none of it is needed in-tree** (ADR-011 §Amendment): no crate **Why none of it is needed in-tree** (ADR-011 §Amendment): no crate
consumer routes through the kernel-net region — the producer drives consumer routes through the kernel-net region; the producer drives
the typestate directly (the ASSOCIATE path is the POC #2 the typestate directly (the ASSOCIATE path is the POC #2
typestate-driving shape; `run_udp_proxy_custom` is unusable on typestate-driving shape; `run_udp_proxy_custom` is unusable on
channels regardless), the consumer sessions use `use_stream` plus channels regardless), the consumer sessions use `use_stream` plus
@@ -99,16 +99,16 @@ reference checkout, as an additive decision (ADR-013).
## The Deliberate Modifications (the fork's diff vs upstream v1.0.0) ## The Deliberate Modifications (the fork's diff vs upstream v1.0.0)
The fork's diff vs upstream v1.0.0 is deliberate and bounded — The fork's diff vs upstream v1.0.0 is deliberate and bounded:
**two changes touch the forked code**; the other two of the original **two changes touch the forked code**; the other two of the original
four dissolve with the kernel-net region: four dissolve with the kernel-net region:
1. **BIND** — absent upstream (every handler refuses 1. **BIND**: absent upstream (every handler refuses
`Socks5Command::TCPBind`). The crate drives the typestate itself `Socks5Command::TCPBind`). The crate drives the typestate itself
for the BIND shape (ADR-007); the fork carries the shape in-tree for the BIND shape (ADR-007); the fork carries the shape in-tree
(`reply_success`'s `sock_addr` as the reply#1 carrier; the (`reply_success`'s `sock_addr` as the reply#1 carrier; the
reply#2 writer using the published `new_reply`). reply#2 writer using the published `new_reply`).
2. **`new_reply` publication** — the reply encoder becomes public 2. **`new_reply` publication**: the reply encoder becomes public
(a `reply(code, addr)`-shaped constructor) so the BIND reply#2 (a `reply(code, addr)`-shaped constructor) so the BIND reply#2
writer doesn't re-encode the 10/22-byte reply form. writer doesn't re-encode the 10/22-byte reply form.
@@ -116,10 +116,10 @@ Dissolved: the `net` feature gate (no net code in-tree) and the
`run_udp_proxy_custom` bind seam (the handler isn't forked). `run_udp_proxy_custom` bind seam (the handler isn't forked).
Plus the **owned-code post-fork changes** (the fork is first-class Plus the **owned-code post-fork changes** (the fork is first-class
code — extend this list, never replace it; convention 17): code; extend this list, never replace it; convention 17):
- **The keep-or-cut sweep** (review 002 N-1): the producer-replaced - **The keep-or-cut sweep** (review 002 N-1): the producer-replaced
machinery is cut from the typestate — the `auth_method_enums!` machinery is cut from the typestate: the `auth_method_enums!`
macro (upstream's enum generator) with its generated macro (upstream's enum generator) with its generated
`StandardAuthentication` enum + `allow_no_auth`, `StandardAuthentication` enum + `allow_no_auth`,
`skip_auth_this_is_not_rfc_compliant`, and `CheckResult`'s `skip_auth_this_is_not_rfc_compliant`, and `CheckResult`'s
@@ -130,11 +130,11 @@ code — extend this list, never replace it; convention 17):
typestate's). Kept with note (the hand-port map, ADR-013): typestate's). Kept with note (the hand-port map, ADR-013):
`ConnectError` (constructed nowhere in-tree; the dial-error → `ConnectError` (constructed nowhere in-tree; the dial-error →
reply-code mapping a future kernel-net port reuses) and reply-code mapping a future kernel-net port reuses) and
`transfer`/`wait_on_tcp` (differential-suite-consumed — their `transfer`/`wait_on_tcp` (differential-suite-consumed; their
semantics stay pinned there). The upstream `Bug` variant is semantics stay pinned there). The upstream `Bug` variant is
likewise kept, its display intact. likewise kept, its display intact.
- **The refusal vocabulary variant** (review 002 N-4): - **The refusal vocabulary variant** (review 002 N-4):
`SocksServerError::Refused(&'static str)` — the honest `SocksServerError::Refused(&'static str)`: the honest
log/observability vocabulary for the producer's in-stream policy log/observability vocabulary for the producer's in-stream policy
refusals (the command gate + the four seam-refusal sites). Upstream refusals (the command gate + the four seam-refusal sites). Upstream
has no such variant; it is an owned addition, never reaching the has no such variant; it is an owned addition, never reaching the
@@ -144,7 +144,7 @@ code — extend this list, never replace it; convention 17):
Plus the provenance notes: the fork point (upstream v1.0.0, Plus the provenance notes: the fork point (upstream v1.0.0,
extraction `57b107b..5b18d4d`, fork at the review commit) and the extraction `57b107b..5b18d4d`, fork at the review commit) and the
license notice (MIT, extended — ADR-013's license mechanics). license notice (MIT, extended; ADR-013's license mechanics).
Plus the **extraction-time adjustments** (mechanical edits applied Plus the **extraction-time adjustments** (mechanical edits applied
at extraction, before the fork): at extraction, before the fork):
@@ -154,8 +154,8 @@ at extraction, before the fork):
out of RFC 1928 scope). `SocksError`'s `ReplySocks4Error` variant out of RFC 1928 scope). `SocksError`'s `ReplySocks4Error` variant
(also `#[cfg(feature = "socks4")]` upstream) goes with it. (also `#[cfg(feature = "socks4")]` upstream) goes with it.
- The legacy `Config<A>`/`Authentication` auth layer is out - The legacy `Config<A>`/`Authentication` auth layer is out
(ungated upstream, but consumed only by the cut legacy/net region (ungated upstream, but consumed only by the cut legacy/net region:
— `Socks5Server`/`Socks5Socket`/the `run_*` handlers; the `Socks5Server`/`Socks5Socket`/the `run_*` handlers; the
typestate's own `accept_no_auth`/`accept_password_auth` are the typestate's own `accept_no_auth`/`accept_password_auth` are the
crate's in-band auth surface). With it go `async-trait` (its four crate's in-band auth surface). With it go `async-trait` (its four
upstream uses are all on this layer), the example auth impls upstream uses are all on this layer), the example auth impls
@@ -165,14 +165,14 @@ at extraction, before the fork):
- Upstream `log` calls in the extracted region are dropped or - Upstream `log` calls in the extracted region are dropped or
re-pointed at the crate's `tracing`. re-pointed at the crate's `tracing`.
- Upstream `anyhow` error wrapping (`Context` / `Other(#[from] - Upstream `anyhow` error wrapping (`Context` / `Other(#[from]
anyhow::Error)`) is dropped — the crate's error surface is anyhow::Error)`) is dropped; the crate's error surface is
`thiserror`-typed; the forked `SocksError`/`SocksServerError` `thiserror`-typed; the forked `SocksError`/`SocksServerError`
stay the wire-level vocabulary. stay the wire-level vocabulary.
- The panic paths in the extracted region are translated (AGENTS.md - The panic paths in the extracted region are translated (AGENTS.md
convention 2 — no panics in library code): `ReplyError::from_u8`'s convention 2: no panics in library code): `ReplyError::from_u8`'s
upstream `unreachable!()` arm for codes outside the RFC table upstream `unreachable!()` arm for codes outside the RFC table
maps unassigned codes (`0x09`–`0xFF`, RFC 1928 §6) to maps unassigned codes (`0x09`–`0xFF`, RFC 1928 §6) to
`GeneralFailure` instead — the value comes off the wire `GeneralFailure` instead; the value comes off the wire
(`Socks5Stream::read_request_reply` feeds the raw reply byte (`Socks5Stream::read_request_reply` feeds the raw reply byte
there), so a misbehaving peer surfaces as a typed error, never a there), so a misbehaving peer surfaces as a typed error, never a
panic (the extraction review's F-1 correction). panic (the extraction review's F-1 correction).
@@ -186,28 +186,28 @@ at extraction, before the fork):
a request header would corrupt the conversation; byte-identical on a request header would corrupt the conversation; byte-identical on
a healthy stream). `reply_success`/`reply_error` were likewise a healthy stream). `reply_success`/`reply_error` were likewise
converted at the fork review (001 F-1): replies are small, converted at the fork review (001 F-1): replies are small,
single-shot frames that must be all-or-nothing — a partial write single-shot frames that must be all-or-nothing; a partial write
desynchronizes the conversation silently. (The desynchronizes the conversation silently. (The
`clippy::unused_io_amount` lint — deny-by-default — does flag bare `clippy::unused_io_amount` lint, deny-by-default, does flag bare
`.write()` on `AsyncWriteExt`, but the `err_when`-chaining idiom `.write()` on `AsyncWriteExt`, but the `err_when`-chaining idiom
type-infers the result through a generic blanket impl and suppresses type-infers the result through a generic blanket impl and suppresses
it; the linter is not a substitute for the conversion.) it; the linter is not a substitute for the conversion.)
Plus the **post-fork convention fixes** (ADR-013 — the lint-allow Plus the **post-fork convention fixes** (ADR-013; the lint-allow
posture dissolved, project conventions applied): the module-level posture dissolved, project conventions applied): the module-level
`#[allow]`s removed and the suppressed smells fixed `#[allow]`s removed and the suppressed smells fixed
(`u8::max_value()` → `u8::MAX`, needless lifetimes elided, (`u8::max_value()` → `u8::MAX`, needless lifetimes elided,
`match_like_matches_macro` applied, `io::Error::new(Other)` → `match_like_matches_macro` applied, `io::Error::new(Other)` →
`io::Error::other`, `wrong_self_convention` fixed on `AuthMethod:: `io::Error::other`, `wrong_self_convention` fixed on `AuthMethod::
new`, the `ToSocketAddrs` error string re-worded — it no longer new`, and the `ToSocketAddrs` error string re-worded (it no longer
names the cut `resolve_dns()` — and the `#[allow(dead_code)]` on names the cut `resolve_dns()`); the `#[allow(dead_code)]` on
`AuthenticationMethod` swept). These are one-time, recorded for `AuthenticationMethod` swept). These are one-time, recorded for
completeness; the fork is first-class code thereafter. completeness; the fork is first-class code thereafter.
## Maintenance Posture ## Maintenance Posture
- **The fork is first-class code**: it co-evolves with the wrapper - **The fork is first-class code**: it co-evolves with the wrapper
under the project conventions, unconditionally — no second under the project conventions, unconditionally: no second
instruction set, no adjustment-list apparatus, no verbatim instruction set, no adjustment-list apparatus, no verbatim
preservation (ADR-013). preservation (ADR-013).
- **Differential testing**: `/workspace/fast-socks5` stays the - **Differential testing**: `/workspace/fast-socks5` stays the
@@ -215,19 +215,19 @@ completeness; the fork is first-class code thereafter.
domain addressing, fragmentation). The crate's test suite covers domain addressing, fragmentation). The crate's test suite covers
the edge cases the forked code must keep passing; when in doubt, the edge cases the forked code must keep passing; when in doubt,
diff behavior against the reference checkout. The kernel-net diff behavior against the reference checkout. The kernel-net
region lives there too — the reference checkout is the only place region lives there too; the reference checkout is the only place
the full upstream surface exists. the full upstream surface exists.
- **Upstream posture**: fast-socks5 is third-party; nothing waits - **Upstream posture**: fast-socks5 is third-party; nothing waits
on it, nothing is absorbed from it. A future upstream fix that on it, nothing is absorbed from it. A future upstream fix that
matters is **hand-ported** against the reference checkout and matters is **hand-ported** against the reference checkout and
guarded by the differential suite (ADR-013's honest cost — guarded by the differential suite (ADR-013's honest cost,
assessed low-risk: a quiet upstream, a well-trodden RFC surface). assessed low-risk: a quiet upstream, a well-trodden RFC surface).
The offering-a-PR-upstream courtesy item is retired with the The offering-a-PR-upstream courtesy item is retired with the
vendor posture (the net gate dissolved with the kernel-net vendor posture (the net gate dissolved with the kernel-net
region; there is no in-tree need to offer). region; there is no in-tree need to offer).
- **Provenance is permanent**: the module docs and the bundled - **Provenance is permanent**: the module docs and the bundled
license notice always name the origin (fast-socks5 v1.0.0, the license notice always name the origin (fast-socks5 v1.0.0, the
extraction range, the fork point) — orthogonal to ownership, kept extraction range, the fork point), orthogonal to ownership, kept
so future readers can always see where the code came from so future readers can always see where the code came from
(ADR-013). (ADR-013).
- **No second codec, ever**: the forked codecs are the only RFC - **No second codec, ever**: the forked codecs are the only RFC
@@ -252,10 +252,10 @@ None currently blocking this document.
- OQ-SK-04 (the resolved posture), OQ-SK-08 (BIND + `new_reply`), - OQ-SK-04 (the resolved posture), OQ-SK-08 (BIND + `new_reply`),
OQ-SK-03 (the bind seam) OQ-SK-03 (the bind seam)
- POC #4 (`poc-connect-wasm-findings.md` — the measured gate), POC - POC #4 (`poc-connect-wasm-findings.md`: the measured gate), POC
#5 (`poc-bind-findings.md` — the BIND driving + the `new_reply` #5 (`poc-bind-findings.md`: the BIND driving + the `new_reply`
finding), POC #2 (the typestate-driving shape for ASSOCIATE) finding), POC #2 (the typestate-driving shape for ASSOCIATE)
- `tasks/implementation/review-vendor-extraction.md` (the fidelity - `tasks/implementation/review-vendor-extraction.md` (the fidelity
gate; F-1) gate; F-1)
- `/workspace/fast-socks5` (v1.0.0, MIT — the behavioral reference); - `/workspace/fast-socks5` (v1.0.0, MIT, the behavioral reference);
`docs/research/iroh-socks5-eval.md` (why not the alternative base) `docs/research/iroh-socks5-eval.md` (why not the alternative base)
+41 -41
View File
@@ -1,19 +1,19 @@
--- ---
status: draft status: draft
last_updated: 2026-09-24 (the phase lifetimes' timeout posture decided — last_updated: 2026-09-24 (the phase lifetimes' timeout posture decided:
teardown-governed, no protocol-layer idle bound, ADR-014; Phase 1 teardown-governed, no protocol-layer idle bound, ADR-014; Phase 1
initial draft 2026-09-20 — the POC-validated wire shapes promoted initial draft 2026-09-20; the POC-validated wire shapes promoted
into the normative spec) into the normative spec)
--- ---
# alksocks — Wire Format # alksocks: Wire Format
The `alk/socks5` wire surface. Two layers: the **open op** (JSON The `alk/socks5` wire surface. Two layers: the **open op** (JSON
params on the channels call plane — trivial here, ADR-001) and the params on the channels call plane; trivial here, ADR-001) and the
**data plane** (the RFC 1928 conversation + the data streams inside **data plane** (the RFC 1928 conversation + the data streams inside
the channel `BiStream`). The channels layer strips its own 8-byte the channel `BiStream`). The channels layer strips its own 8-byte
header transparently (alknet ADR-093 / alkcall ADR-035); alksocks header transparently (alknet ADR-093 / alkcall ADR-035); alksocks
owns everything inside the `BiStream` — which is, per the phase owns everything inside the `BiStream`, which is, per the phase
model (ADR-003/ADR-007), the RFC conversation itself plus raw model (ADR-003/ADR-007), the RFC conversation itself plus raw
pass-through, with exactly one framed stage (ASSOCIATE datagrams). pass-through, with exactly one framed stage (ASSOCIATE datagrams).
@@ -23,14 +23,14 @@ Design decisions live in the ADRs ([001](decisions/001-alpn-and-open-op.md),
[014](decisions/014-idle-timeout-posture.md)); this document is the normative [014](decisions/014-idle-timeout-posture.md)); this document is the normative
WHAT. RFC 1928's own wire rules (method negotiation, command encoding, WHAT. RFC 1928's own wire rules (method negotiation, command encoding,
reply encoding, the RFC UDP datagram header) are specified by the RFC reply encoding, the RFC UDP datagram header) are specified by the RFC
itself — this document covers the channels-specific surface and the itself; this document covers the channels-specific surface and the
phase model's stage boundaries. phase model's stage boundaries.
## The Open Op (`channels/socks5/sub`) ## The Open Op (`channels/socks5/sub`)
`Sub`-typed operation (alkcall ADR-047): the reply carries `Sub`-typed operation (alkcall ADR-047): the reply carries
`{ "channel_id": <u32> }` once; everything else — the entire RFC 1928 `{ "channel_id": <u32> }` once; everything else (the entire RFC 1928
conversation and the data plane — flows on the channel's `BiStream`, conversation and the data plane) flows on the channel's `BiStream`,
never in the call response stream. ALPN marker `alk/socks5` (ADR-001). never in the call response stream. ALPN marker `alk/socks5` (ADR-001).
### Input (params) ### Input (params)
@@ -41,21 +41,21 @@ never in the call response stream. ALPN marker `alk/socks5` (ADR-001).
- Trivial: the SOCKS5 target is unknown at open time (it arrives - Trivial: the SOCKS5 target is unknown at open time (it arrives
in-stream post-negotiation); the establisher is a session gate, not in-stream post-negotiation); the establisher is a session gate, not
a dial (POC #1's structural finding — ADR-004). a dial (POC #1's structural finding, ADR-004).
- Schema: `{type: object}` with `additionalProperties: false`. Empty - Schema: `{type: object}` with `additionalProperties: false`. Empty
params stay valid forever; a future per-session option would be an params stay valid forever; a future per-session option would be an
additive schema extension (ADR-001). additive schema extension (ADR-001).
### Reply / errors ### Reply / errors
- Success: `{ "channel_id": <u32> }` — the establishment phase (the - Success: `{ "channel_id": <u32> }`: the establishment phase (the
session gate) succeeded, the open handler is running. session gate) succeeded, the open handler is running.
- Failure: a typed `channel:open_failed` call error with - Failure: a typed `channel:open_failed` call error with
`details: { reason, message }` — `reason ∈ dial_failed` / `details: { reason, message }` where `reason ∈ dial_failed` /
`unknown_resource` / `resource_shortage` / `handler_error` / `unknown_resource` / `resource_shortage` / `handler_error` /
`timeout` (alkcall ADR-049 §3). The registry's ACL gate runs `timeout` (alkcall ADR-049 §3). The registry's ACL gate runs
before the wrapper (`FORBIDDEN` for a scope-less identity — before the wrapper (`FORBIDDEN` for a scope-less identity;
POC #1). **A failed open never returns a `channel_id`** — no POC #1). **A failed open never returns a `channel_id`**: no
phantom channel. Session-level refusal is always channels-level; phantom channel. Session-level refusal is always channels-level;
*target-level* refusal happens later, in-stream, as an RFC reply *target-level* refusal happens later, in-stream, as an RFC reply
code (ADR-004). code (ADR-004).
@@ -71,7 +71,7 @@ never in the call response stream. ALPN marker `alk/socks5` (ADR-001).
RFC 1928 is one command per connection, and control and data never RFC 1928 is one command per connection, and control and data never
interleave in either direction. The channel's `BiStream` therefore interleave in either direction. The channel's `BiStream` therefore
has a **phase structure with no mode byte** — the reply itself has a **phase structure with no mode byte**: the reply itself
synchronizes the switch (POC #2 confirmed post-reply garbage is synchronizes the switch (POC #2 confirmed post-reply garbage is
refused structurally; the demux-vs-phase trade is resolved by refused structurally; the demux-vs-phase trade is resolved by
ADR-003/ADR-007): ADR-003/ADR-007):
@@ -88,18 +88,18 @@ ADR-003/ADR-007):
any post-reply control byte parses as a garbage ATYP and fails the any post-reply control byte parses as a garbage ATYP and fails the
association loud (`udp_undecodable_frame_ends_association`, POC association loud (`udp_undecodable_frame_ends_association`, POC
#2). BIND: any byte in the quiet wait ends the session with no #2). BIND: any byte in the quiet wait ends the session with no
data plane (POC #5 — the RFC 1928 §3 reading). data plane (POC #5, the RFC 1928 §3 reading).
- **No in-band control vocabulary.** The alktunnels resolution - **No in-band control vocabulary.** The alktunnels resolution
("no in-band control path, ever — the escape hatch is a new ALPN") ("no in-band control path, ever; the escape hatch is a new ALPN")
holds: no KEEPALIVE, no error frames, no flow-expiry frames. No holds: no KEEPALIVE, no error frames, no flow-expiry frames. No
idle/inactivity vocabulary either — every phase's lifetime is idle/inactivity vocabulary either: every phase's lifetime is
teardown-governed (EOF / garbage / error sentinels), never teardown-governed (EOF / garbage / error sentinels), never
protocol-layer timed (ADR-014; ADR-007 decided the BIND quiet protocol-layer timed (ADR-014; ADR-007 decided the BIND quiet
wait's ends the same way). A deployment enforces idle policy at wait's ends the same way). A deployment enforces idle policy at
its edges, not in-band. Errors are EOF-shaped (channels-level) or its edges, not in-band. Errors are EOF-shaped (channels-level) or
reply-code-shaped (in-stream, pre-data-plane). reply-code-shaped (in-stream, pre-data-plane).
- **One channel per SOCKS5 session** — no sub-demux, no flow key - **One channel per SOCKS5 session** (no sub-demux, no flow key;
(AGENTS.md convention 10; the ASSOCIATE handle swap makes the AGENTS.md convention 10; the ASSOCIATE handle swap makes the
channel the relay handle, ADR-003). channel the relay handle, ADR-003).
## The Data Plane ## The Data Plane
@@ -107,7 +107,7 @@ ADR-003/ADR-007):
### CONNECT: raw pass-through ### CONNECT: raw pass-through
Post-reply, the handler runs `alkcall::channels::pump_bidi(channel_ Post-reply, the handler runs `alkcall::channels::pump_bidi(channel_
bistream, target_read, target_write)` (ADR-050) — the canonical bistream, target_read, target_write)` (ADR-050), the canonical
two-pump shape (alknet ADR-078); each pump shuts down the opposite two-pump shape (alknet ADR-078); each pump shuts down the opposite
sink on completion (half-close semantics fall out); EOF sentinels sink on completion (half-close semantics fall out); EOF sentinels
are the channels layer's. Zero protocol framing in-band: the RFC are the channels layer's. Zero protocol framing in-band: the RFC
@@ -122,31 +122,31 @@ interception point with the parsed target. Per-target refusal:
### UDP ASSOCIATE: the handle swap + framed datagram stage ### UDP ASSOCIATE: the handle swap + framed datagram stage
The ASSOCIATE reply's `BND.ADDR`/`BND.PORT` is the sentinel The ASSOCIATE reply's `BND.ADDR`/`BND.PORT` is the sentinel
`127.0.0.1:0` on the in-band path — pure RFC-compatibility surface; `127.0.0.1:0` on the in-band path (pure RFC-compatibility surface);
the channel is the relay handle (ADR-003). The datagram stage rides the channel is the relay handle (ADR-003). The datagram stage rides
the same channel: the same channel:
- **Frame:** `[len: u16 BE][addr + data]` where `addr + data` is the - **Frame:** `[len: u16 BE][addr + data]` where `addr + data` is the
RFC address encoding (`TargetAddr::to_be_bytes()` — ATYP + addr + RFC address encoding (`TargetAddr::to_be_bytes()`: ATYP + addr +
port; the RFC UDP header minus RSV/FRAG). One frame per datagram, port; the RFC UDP header minus RSV/FRAG). One frame per datagram,
both directions. **A zero-length payload is a legal empty datagram both directions. **A zero-length payload is a legal empty datagram
(never EOF — the F-2 invariant, shaped on alktunnels ADR-003's (never EOF; the F-2 invariant, shaped on alktunnels ADR-003's
codec, re-implemented in first-party `src/wire.rs`); `len == 0` — codec, re-implemented in first-party `src/wire.rs`); `len == 0`
zero inner bytes — is unreachable from a compliant peer, since (zero inner bytes) is unreachable from a compliant peer, since
`addr` is always ≥ 7 bytes, so the operative invariant lives at `addr` is always ≥ 7 bytes, so the operative invariant lives at
payload level.** The length covers payload level.** The length covers
`addr + data`, not data alone — the joint bound is the `addr + data`, not data alone; the joint bound is the
constraint:** a frame is rejected (`Oversize`, at frame time) constraint:** a frame is rejected (`Oversize`, at frame time)
whenever `len(addr) + len(data) > 65535`. The UDP payload bound whenever `len(addr) + len(data) > 65535`. The UDP payload bound
alone (65507) does *not* fit u16 unconditionally: a 65507-byte alone (65507) does *not* fit u16 unconditionally: a 65507-byte
datagram to a 255-character domain is 65507 + 259 (max domain-form datagram to a 255-character domain is 65507 + 259 (max domain-form
address: ATYP + length byte + 255 + port) = 65766 bytes of frame address: ATYP + length byte + 255 + port) = 65766 bytes of frame
payload — a legal RFC 1928 datagram that would overflow u16. The payload, a legal RFC 1928 datagram that would overflow u16. The
joint rejection keeps the length field honest: max data is 65276 joint rejection keeps the length field honest: max data is 65276
for domain-form destinations, 65516 for IPv6, 65528 for IPv4. for domain-form destinations, 65516 for IPv6, 65528 for IPv4.
Implementations must not use unchecked u16 arithmetic on the Implementations must not use unchecked u16 arithmetic on the
payload bound. payload bound.
- **RSV/FRAG do not ride the tunnel wire** — fragmentation cannot be - **RSV/FRAG do not ride the tunnel wire**: fragmentation cannot be
expressed in-band; FRAG ≠ 0 is refused at front doors (the expressed in-band; FRAG ≠ 0 is refused at front doors (the
iroh-socks5 v1 posture). iroh-socks5 v1 posture).
- **The RFC header codec (`new_udp_header`/`parse_udp_request`) - **The RFC header codec (`new_udp_header`/`parse_udp_request`)
@@ -157,7 +157,7 @@ the same channel:
channels-level sentinel) ends the association; no extra control channels-level sentinel) ends the association; no extra control
vocabulary (POC #2 test 4: a fresh associate succeeds immediately vocabulary (POC #2 test 4: a fresh associate succeeds immediately
after a half-close teardown). No inactivity timer exists at the after a half-close teardown). No inactivity timer exists at the
protocol layer — every phase (the pre-command handshake, the BIND protocol layer: every phase (the pre-command handshake, the BIND
quiet wait, the association) is teardown-governed, never quiet wait, the association) is teardown-governed, never
idle-expired (ADR-014); a deployment wanting an idle bound enforces idle-expired (ADR-014); a deployment wanting an idle bound enforces
it outside the protocol layer (door accept paths, caller-side it outside the protocol layer (door accept paths, caller-side
@@ -165,7 +165,7 @@ the same channel:
- **The stage is `select!`-driven** (channel reads ∥ egress replies), - **The stage is `select!`-driven** (channel reads ∥ egress replies),
with the egress readiness contract: `next_reply` is `Pending` when with the egress readiness contract: `next_reply` is `Pending` when
nothing is ready (an instantly-ready `Ok(None)` starves a biased nothing is ready (an instantly-ready `Ok(None)` starves a biased
select into livelock — POC #2's load-bearing API finding). select into livelock; POC #2's load-bearing API finding).
**Framing is applied exactly once, by the wire layer:** **Framing is applied exactly once, by the wire layer:**
`Egress::next_reply` returns *inner* bytes (`addr + data`); the `Egress::next_reply` returns *inner* bytes (`addr + data`); the
frame writer adds the single length prefix (the double-framing bug frame writer adds the single length prefix (the double-framing bug
@@ -178,7 +178,7 @@ the same channel:
open cost ~0.8 ms measured, per-target ACL for free). open cost ~0.8 ms measured, per-target ACL for free).
**Per-datagram `send` is awaited inline** (the stage interleaves **Per-datagram `send` is awaited inline** (the stage interleaves
sends with reply drains); a slow egress applies backpressure to sends with reply drains); a slow egress applies backpressure to
the association, not to other channels — the batch-2 the association, not to other channels; the batch-2
composed-egress author inherits this bound knowingly. composed-egress author inherits this bound knowingly.
### BIND: the phase model extended (ADR-007) ### BIND: the phase model extended (ADR-007)
@@ -187,13 +187,13 @@ the same channel:
variant's real listener address; the sentinel on the wrapper-aware variant's real listener address; the sentinel on the wrapper-aware
in-band path). in-band path).
- **The quiet wait**: no bytes are legal; the `select!` is - **The quiet wait**: no bytes are legal; the `select!` is
single-shot with exactly two exits — the one inbound accept (from single-shot with exactly two exits: the one inbound accept (from
the `BindAcceptFactory` seam, whose future owns the listener), or the `BindAcceptFactory` seam, whose future owns the listener), or
the control stream's EOF/garbage/error (the client-gone teardown the control stream's EOF/garbage/error (the client-gone teardown
riding the accept future's drop). riding the accept future's drop).
- **reply#2 carries the peer address** (the real peer on both - **reply#2 carries the peer address** (the real peer on both
non-sentinel variants — local: `accept()`'s peer, byte-exact; non-sentinel variants: local is `accept()`'s peer, byte-exact;
composed: the forwarded op's `peer` param since the bind-first composed is the forwarded op's `peer` param since the bind-first
graduation, alktunnels `docs/research/tunnels-graduation.md`; graduation, alktunnels `docs/research/tunnels-graduation.md`;
sentinel `0.0.0.0:0` wrapper-aware). sentinel `0.0.0.0:0` wrapper-aware).
- **Phase 2 is raw pass-through** (`pump_bidi`, the CONNECT shape) - **Phase 2 is raw pass-through** (`pump_bidi`, the CONNECT shape)
@@ -204,7 +204,7 @@ the same channel:
### Sentinels and lifecycle (all commands) ### Sentinels and lifecycle (all commands)
- **EOF** is the channels-level zero-length chunk — per-direction - **EOF** is the channels-level zero-length chunk, per-direction
(half-close); written by `pump_bidi`'s shutdown-on-completion or (half-close); written by `pump_bidi`'s shutdown-on-completion or
teardown. CONNECT/BIND data planes end on EOF; the ASSOCIATE teardown. CONNECT/BIND data planes end on EOF; the ASSOCIATE
association ends on EOF. association ends on EOF.
@@ -212,7 +212,7 @@ the same channel:
whole point); pump-phase failures are EOF-shaped (alkcall whole point); pump-phase failures are EOF-shaped (alkcall
ADR-049 §6). ADR-049 §6).
- **BAST:** the ASSOCIATE datagram frame is the one binary framing - **BAST:** the ASSOCIATE datagram frame is the one binary framing
beyond pass-through — its BAST document is [bast.md](bast.md). beyond pass-through; its BAST document is [bast.md](bast.md).
## Normative byte diagrams ## Normative byte diagrams
@@ -227,7 +227,7 @@ UDP ASSOCIATE (the datagram stage, one direction's view):
``` ```
[len:u16 BE][ATYP + addr + port + data] [len:u16 BE][...] ... [len:u16 BE][ATYP + addr + port + data] [len:u16 BE][...] ...
zero-length data: an empty datagram (legal payload, NOT EOF — zero-length data: an empty datagram (legal payload, NOT EOF;
the F-2 invariant at payload level) the F-2 invariant at payload level)
len == 0 (no addr bytes): unreachable from a compliant peer len == 0 (no addr bytes): unreachable from a compliant peer
EOF: the channels-level sentinel, outside the codec EOF: the channels-level sentinel, outside the codec
@@ -244,9 +244,9 @@ BIND (the phase sequence, one channel's view):
## Open Questions ## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md). Open questions are tracked in [open-questions.md](open-questions.md).
None currently open against this document — the datagram framing None currently open against this document: the datagram framing
(the one-way door) is decided (ADR-003); the phase lifetimes' (the one-way door) is decided (ADR-003); the phase lifetimes'
timeout posture is decided (ADR-014 — teardown-governed, no timeout posture is decided (ADR-014: teardown-governed, no
protocol-layer idle bound); OQ-SK2-07 (the virtual-subnet protocol-layer idle bound); OQ-SK2-07 (the virtual-subnet
translation layer) rides the dial callback and does not touch this translation layer) rides the dial callback and does not touch this
surface. surface.
@@ -256,7 +256,7 @@ surface.
- ADR-001 (op/params), ADR-003 (the handle swap + frame), - ADR-001 (op/params), ADR-003 (the handle swap + frame),
ADR-004 (the seams + phase-model refusal paths), ADR-007 (BIND), ADR-004 (the seams + phase-model refusal paths), ADR-007 (BIND),
ADR-011 (the forked codecs), ADR-014 (the teardown-governed ADR-011 (the forked codecs), ADR-014 (the teardown-governed
phase lifetimes — no protocol-layer idle timeout) phase lifetimes; no protocol-layer idle timeout)
- POC findings: `poc-connect-wasm-findings.md` (CONNECT pass-through, - POC findings: `poc-connect-wasm-findings.md` (CONNECT pass-through,
refusal mapping), `poc-udp-associate-findings.md` (the stage, refusal mapping), `poc-udp-associate-findings.md` (the stage,
readiness contract, framing-once invariant, association lifetime), readiness contract, framing-once invariant, association lifetime),
@@ -266,4 +266,4 @@ surface.
(channel open), ADR-049 (establishment/typed errors), ADR-050 (channel open), ADR-049 (establishment/typed errors), ADR-050
(`pump_bidi`); alktunnels ADR-003 (the length codec + F-2) (`pump_bidi`); alktunnels ADR-003 (the length codec + F-2)
- RFC 1928 (the protocol's own wire rules), iroh-socks5 eval - RFC 1928 (the protocol's own wire rules), iroh-socks5 eval
(`docs/research/iroh-socks5-eval.md` — the frame prior art) (`docs/research/iroh-socks5-eval.md`, the frame prior art)
+78 -78
View File
@@ -1,52 +1,52 @@
# Fuzzing alksocks: Research and Plan # Fuzzing alksocks: Research and Plan
**Status:** adopted — wave-1 targets (`datagram_codec`, `rfc1928_codec`, **Status:** adopted; wave-1 targets (`datagram_codec`, `rfc1928_codec`,
`params_parse`) implemented, seeds committed, campaigns run clean (§7). `params_parse`) implemented, seeds committed, campaigns run clean (§7).
Wave 2 (`session_pumps`) deferred to post-0.1 (§4). Campaigns run via the Wave 2 (`session_pumps`) deferred to post-0.1 (§4). Campaigns run via the
detached runner (§6) — **never as a foreground child of an agent detached runner (§6): **never as a foreground child of an agent
session** (§5). session** (§5).
**Date:** 2026-09-28 **Date:** 2026-09-28
**Upstream precedent:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` **Upstream precedent:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md`
(one real finding: the duplicate adopt/open destroying the live channel — (one real finding: the duplicate adopt/open destroying the live channel,
fixed in 0.8.1) and fixed in 0.8.1) and
`/workspace/@alkdev/alktunnels/docs/research/fuzzing.md` (wave-1/wave-2 `/workspace/@alkdev/alktunnels/docs/research/fuzzing.md` (wave-1/wave-2
adopted, six harness-model findings, no crate bugs). Tooling, corpus adopted, six harness-model findings, no crate bugs). Tooling, corpus
policy, runner, and the no-hosted-CI posture are inherited from those policy, runner, and the no-hosted-CI posture are inherited from those
docs wholesale — this doc does not re-litigate them; it scopes them to docs wholesale; this doc does not re-litigate them. It scopes them to
this crate. this crate.
--- ---
## 1. Should this crate be fuzzed? ## 1. Should this crate be fuzzed?
**Yes — and alkcall's fuzz doc already assigned this to us.** Three **Yes, and alkcall's fuzz doc already assigned this to us.** Three
lines, all pointing the same way: lines, all pointing the same way:
1. **alkcall's fuzz doc §5 named this crate's surfaces as downstream 1. **alkcall's fuzz doc §5 named this crate's surfaces as downstream
targets.** Its "non-surfaces (deliberately)" note ends: *the channels targets.** Its "non-surfaces (deliberately)" note ends: *the channels
layer carries opaque `Bytes` and the handler owns sub-stream framing layer carries opaque `Bytes` and the handler owns sub-stream framing
(ADR-035) — those downstream parsers are downstream crates' fuzz (ADR-035); those downstream parsers are downstream crates' fuzz
targets.* Our `src/wire.rs` datagram codec **is** that named targets. Our `src/wire.rs` datagram codec **is** that named
downstream target (shaped on alktunnels' codec, which alktunnels downstream target (shaped on alktunnels' codec, which alktunnels
fuzzed as its wave-1 first target — the same assignment, landed fuzzed as its wave-1 first target; the same assignment, landed
here). Fuzzing it completes the upstream scope rather than here). Fuzzing it completes the upstream scope rather than
duplicating it. duplicating it.
2. **We own an entire RFC handshake state machine fed by arbitrary 2. **We own an entire RFC handshake state machine fed by arbitrary
peers — on both sides.** The targeted fork (`src/rfc1928/`, ADR-013) peers, on both sides.** The targeted fork (`src/rfc1928/`, ADR-013)
is fully owned code with project conventions applying unconditionally is fully owned code with project conventions applying unconditionally
(convention 17). Its parse surfaces — `read_address` (the address (convention 17). Its parse surfaces, namely `read_address` (the address
codec), `negotiate_auth` (method negotiation), `read_command` (the codec), `negotiate_auth` (method negotiation), `read_command` (the
command encoding), `read_username_password` (RFC 1929), the client's command encoding), `read_username_password` (RFC 1929) and the client's
`read_request_reply` — are fed bytes produced by **arbitrary peers** `read_request_reply`, are fed bytes produced by **arbitrary peers**
in both directions: the producer drives the server typestate against in both directions: the producer drives the server typestate against
consumer bytes, and the consumer sessions + front doors drive the consumer bytes, and the consumer sessions + front doors drive the
client path against producer bytes. An RFC state machine is exactly client path against producer bytes. An RFC state machine is exactly
the shape the quinn-proto CVE class (RUSTSEC-2026-0037 — remote DoS the shape the quinn-proto CVE class (RUSTSEC-2026-0037, remote DoS
via `unwrap()` in parsing, the upstream doc §1's strongest via `unwrap()` in parsing, the upstream doc §1's strongest
datapoint) lives in: hand-rolled state transitions, length arithmetic, datapoint) lives in: hand-rolled state transitions, length arithmetic,
and boundary handling. One fork-carried correction is already of this and boundary handling. One fork-carried correction is already of this
class — upstream's `ReplyError::from_u8` ended in `unreachable!()` class: upstream's `ReplyError::from_u8` ended in `unreachable!()`
for unassigned reply codes; the fork made it a typed catch-all (the for unassigned reply codes; the fork made it a typed catch-all (the
review's F-1) precisely because the value comes off the wire. A fuzz review's F-1) precisely because the value comes off the wire. A fuzz
target is how that posture is *demonstrated* across the whole codec, target is how that posture is *demonstrated* across the whole codec,
@@ -54,13 +54,13 @@ lines, all pointing the same way:
3. **The differential suite's blind spot is exactly fuzzing's 3. **The differential suite's blind spot is exactly fuzzing's
strength.** `tests/rfc1928-differential.rs` pins our fork's behavior strength.** `tests/rfc1928-differential.rs` pins our fork's behavior
against the reference checkout on *valid* RFC edge cases — curated against the reference checkout on *valid* RFC edge cases, curated
inputs a human thought of. What no example-based suite provides: inputs a human thought of. What no example-based suite provides:
random *malformed* input exploration (the panic-on-attacker-bytes random *malformed* input exploration (the panic-on-attacker-bytes
class), stateful interleavings, and resource-bound assertions under class), stateful interleavings, and resource-bound assertions under
adversarial sequences. The fork is about to be published (0.1) as adversarial sequences. The fork is about to be published (0.1) as
wire-stable; after that, targets are regression protection for a wire-stable; after that, targets are regression protection for a
frozen surface — cheapest now. frozen surface (cheapest now).
**The honest caveat** (mirroring both upstream docs): the crate has no **The honest caveat** (mirroring both upstream docs): the crate has no
`unsafe` (checked at the crate root), the codec's decode direction is `unsafe` (checked at the crate root), the codec's decode direction is
@@ -72,7 +72,7 @@ more than 255), and the fork's error vocabulary is typed end-to-end
Expected yield is therefore not memory-safety bugs; it is (a) the Expected yield is therefore not memory-safety bugs; it is (a) the
demonstration that the infallibility/no-panic contracts hold on demonstration that the infallibility/no-panic contracts hold on
adversarial input, (b) the decoder-state bound assertion (alktunnels adversarial input, (b) the decoder-state bound assertion (alktunnels
§1.3's finding class — a peer chunk amplifying into unbounded state), §1.3's finding class: a peer chunk amplifying into unbounded state),
and (c) regression protection for the published wire surface. If the and (c) regression protection for the published wire surface. If the
campaigns find nothing, that is the successful-negative-result posture campaigns find nothing, that is the successful-negative-result posture
alkcall's §1 codified. alkcall's §1 codified.
@@ -84,7 +84,7 @@ Identical to both upstream docs: **cargo-fuzz 0.13.2 + `arbitrary`
`fuzz/rust-toolchain.toml`, the crate itself stays stable at MSRV 1.88. `fuzz/rust-toolchain.toml`, the crate itself stays stable at MSRV 1.88.
AFL optional/deferred; bolero deferred (the async-friction argument); AFL optional/deferred; bolero deferred (the async-friction argument);
libafl/honggfuzz/OSS-Fuzz not adopted. Sanitizer: `--sanitizer=address` libafl/honggfuzz/OSS-Fuzz not adopted. Sanitizer: `--sanitizer=address`
with debug assertions (cargo-fuzz defaults) — no MSAN (no `unsafe` in with debug assertions (cargo-fuzz defaults); no MSAN (no `unsafe` in
this crate). this crate).
**Layout (the upstream pattern, load-bearing):** **Layout (the upstream pattern, load-bearing):**
@@ -97,7 +97,7 @@ fuzz/
│ ├── datagram_codec.rs │ ├── datagram_codec.rs
│ ├── rfc1928_codec.rs │ ├── rfc1928_codec.rs
│ └── params_parse.rs │ └── params_parse.rs
├── shared/ alksocks-fuzz-shared — STABLE-toolchain library ├── shared/ alksocks-fuzz-shared (STABLE-toolchain library)
│ └── src/… invariant logic + corpus replay (§3) │ └── src/… invariant logic + corpus replay (§3)
├── corpus/<target>/ committed hand-made seeds (§3) ├── corpus/<target>/ committed hand-made seeds (§3)
├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern) ├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern)
@@ -106,16 +106,16 @@ fuzz/
``` ```
- **Invariant logic lives in `fuzz/shared/`**, not inline in the target - **Invariant logic lives in `fuzz/shared/`**, not inline in the target
binaries — the stable-toolchain corpus replay binaries: the stable-toolchain corpus replay
(`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME (`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME
invariant functions the fuzzer runs, on stable, in the release invariant functions the fuzzer runs, on stable, in the release
verification checklist. This is alkcall §7.2 tier-3's deliverable verification checklist. This is alkcall §7.2 tier-3's deliverable
minus automation; per alkcall §7.9 (adopted here identically) this minus automation; per alkcall §7.9 (adopted here identically) this
repo has no hosted CI and will not get one — corpus replay as plain repo has no hosted CI and will not get one. Corpus replay as plain
`cargo test` is the fuzz gate. `cargo test` is the fuzz gate.
- **Root `Cargo.toml` needs the explicit `[workspace]` table** - **Root `Cargo.toml` needs the explicit `[workspace]` table**
(`members = ["."]`, `exclude = ["fuzz"]`) and `"fuzz/"` on the (`members = ["."]`, `exclude = ["fuzz"]`) and `"fuzz/"` on the
publish exclude list — without it cargo auto-discovers `fuzz/shared/` publish exclude list; without it cargo auto-discovers `fuzz/shared/`
into the main workspace and the MSRV toolchain tries to build the into the main workspace and the MSRV toolchain tries to build the
nightly-consumed dev-deps (the exact failure alkcall hit). nightly-consumed dev-deps (the exact failure alkcall hit).
- **Corpus policy:** commit hand-made seeds, gitignore grown corpora - **Corpus policy:** commit hand-made seeds, gitignore grown corpora
@@ -129,23 +129,23 @@ Input: raw `&[u8]`. Drives `DatagramReader::feed` + `frame` /
`frame_inner` / `addr_encoding_len`. This is the alktunnels wave-1 `frame_inner` / `addr_encoding_len`. This is the alktunnels wave-1
`datagram_codec` target's shape, scoped to this crate's codec (the `datagram_codec` target's shape, scoped to this crate's codec (the
frame carries `addr + data` inner bytes and decodes to frame carries `addr + data` inner bytes and decodes to
`(TargetAddr, payload)`, not opaque datagrams — the invariants follow `(TargetAddr, payload)`, not opaque datagrams; the invariants follow
the inner structure). Invariants: the inner structure). Invariants:
- **no-panic on any byte sequence** (the baseline). - **no-panic on any byte sequence** (the baseline).
- **infallible reassembly**: `feed` never errors at the reassembly - **infallible reassembly**: `feed` never errors at the reassembly
layer — every frame completes as `Ok` or as one of the four layer; every frame completes as `Ok` or as one of the four
*decode-garbage* `WireError` variants (`EmptyFrame` / `BadAtyp` / *decode-garbage* `WireError` variants (`EmptyFrame` / `BadAtyp` /
`ShortAddress` / `DomainNotUtf8`); `Oversize`/`Address` are `ShortAddress` / `DomainNotUtf8`); `Oversize`/`Address` are
unreachable from `feed` (they are frame-time errors — a shape unreachable from `feed` (they are frame-time errors; a shape
assertion pins the partition). assertion pins the partition).
- **empty-payload F-2 layering**: a decoded `(addr, empty)` pair comes - **empty-payload F-2 layering**: a decoded `(addr, empty)` pair comes
only from a real `[len][addr]` frame whose payload region is empty — only from a real `[len][addr]` frame whose payload region is empty,
never invented by the codec; a `len == 0` frame (zero inner bytes) never invented by the codec; a `len == 0` frame (zero inner bytes)
always surfaces as `EmptyFrame`, never as an empty datagram. always surfaces as `EmptyFrame`, never as an empty datagram.
- **bad-ATYP loudness**: every `BadAtyp` error's byte is the actual - **bad-ATYP loudness**: every `BadAtyp` error's byte is the actual
first inner byte of its frame, and it is not one of the three first inner byte of its frame, and it is not one of the three
RFC ATYPs (the post-reply control-byte semantic — any non-ATYP RFC ATYPs (the post-reply control-byte semantic: any non-ATYP
control byte must fail loud, never guess). control byte must fail loud, never guess).
- **consumption accounting / splitting transparency**: feeding in - **consumption accounting / splitting transparency**: feeding in
3-byte slices yields exactly the datagram sequence the whole-input 3-byte slices yields exactly the datagram sequence the whole-input
@@ -155,14 +155,14 @@ the inner structure). Invariants:
well-framed input re-frames byte-for-byte. well-framed input re-frames byte-for-byte.
- **joint bound**: `frame` rejects iff - **joint bound**: `frame` rejects iff
`addr_encoding_len(addr) + payload.len() > MAX_INNER_LEN`, echoing `addr_encoding_len(addr) + payload.len() > MAX_INNER_LEN`, echoing
`(total, max)` exactly — including the max-domain case where a legal `(total, max)` exactly, including the max-domain case where a legal
RFC payload (65507 to a 255-char domain = 65766) must reject (the RFC payload (65507 to a 255-char domain = 65766) must reject (the
joint-bound reason the codec exists); `frame_inner` rejects iff joint-bound reason the codec exists); `frame_inner` rejects iff
`inner.len() > MAX_INNER_LEN`. The `checked_add` path (payload `inner.len() > MAX_INNER_LEN`. The `checked_add` path (payload
overflow wrapping the u16) rejects, never wraps. overflow wrapping the u16) rejects, never wraps.
- **decoder-state bound**: `buf` + in-flight `current` stay bounded by - **decoder-state bound**: `buf` + in-flight `current` stay bounded by
the fed chunk size plus one frame (header + `MAX_INNER_LEN`) on any the fed chunk size plus one frame (header + `MAX_INNER_LEN`) on any
adversarial input — the alktunnels §1.3 finding-class assertion adversarial input, the alktunnels §1.3 finding-class assertion
(state must never grow with input). The crate's `DatagramReader` (state must never grow with input). The crate's `DatagramReader`
exposes this via `is_mid_frame()` + a new `retained_bytes()` exposes this via `is_mid_frame()` + a new `retained_bytes()`
diagnostic (the alktunnels `retained_bytes()` posture). diagnostic (the alktunnels `retained_bytes()` posture).
@@ -175,11 +175,11 @@ batched frames, split frames, garbage ATYP, non-UTF-8 domains, the
### 3.2 `rfc1928_codec` (the forked RFC 1928 core, `src/rfc1928/`) ### 3.2 `rfc1928_codec` (the forked RFC 1928 core, `src/rfc1928/`)
Input: raw `&[u8]`. Drives the fork's byte-level parsers over tokio Input: raw `&[u8]`. Drives the fork's byte-level parsers over tokio
duplex streams (the in-tree test shape — mock bytes, no sockets): duplex streams (the in-tree test shape, mock bytes, no sockets):
- **`read_address`** (the address codec, both directions consume it): - **`read_address`** (the address codec, both directions consume it):
no-panic on any ATYP/bytes; every failure is a typed `AddrError` no-panic on any ATYP/bytes; every failure is a typed `AddrError`
(`IncorrectAddressType` for unknown ATYP — the F-1 class, already (`IncorrectAddressType` for unknown ATYP, the F-1 class, already
fixed; never a panic); round-trip for every well-formed address fixed; never a panic); round-trip for every well-formed address
(`to_be_bytes` → parse → equal). (`to_be_bytes` → parse → equal).
- **the server handshake** (`negotiate_auth` → `read_command` over a - **the server handshake** (`negotiate_auth` → `read_command` over a
@@ -190,7 +190,7 @@ duplex streams (the in-tree test shape — mock bytes, no sockets):
command + target. command + target.
- **the client reply path** (`read_request_reply`): no-panic on any - **the client reply path** (`read_request_reply`): no-panic on any
server byte sequence; unassigned reply codes map to the server byte sequence; unassigned reply codes map to the
`GeneralFailure` catch-all (the F-1 pin — never `unreachable!()`); `GeneralFailure` catch-all (the F-1 pin, never `unreachable!()`);
version mismatches are typed errors. version mismatches are typed errors.
- **`parse_udp_request`** (the RFC UDP header codec, front doors only - **`parse_udp_request`** (the RFC UDP header codec, front doors only
but still wire-reachable): no-panic; garbage RSV / unknown ATYP / but still wire-reachable): no-panic; garbage RSV / unknown ATYP /
@@ -198,7 +198,7 @@ duplex streams (the in-tree test shape — mock bytes, no sockets):
`new_udp_header`. `new_udp_header`.
- **auth sub-handshakes** (`read_username_password`, RFC 1929): - **auth sub-handshakes** (`read_username_password`, RFC 1929):
no-panic; the u8 length fields bound allocations structurally no-panic; the u8 length fields bound allocations structurally
(≤ 255 bytes — asserted); empty username/password are typed errors; (≤ 255 bytes, asserted); empty username/password are typed errors;
round-trip accept/reject status bytes. round-trip accept/reject status bytes.
- **`ReplyError::from_u8`** full-domain table: every `u8` maps, no - **`ReplyError::from_u8`** full-domain table: every `u8` maps, no
panic, unassigned → catch-all (the direct probe of the F-1 class). panic, unassigned → catch-all (the direct probe of the F-1 class).
@@ -222,16 +222,16 @@ generator) → `Socks5Params` deserialization + `socks5_open_spec()` +
- **no-panic on any JSON-shaped input** (`deny_unknown_fields` - **no-panic on any JSON-shaped input** (`deny_unknown_fields`
dispatch, the registry-spec builder). dispatch, the registry-spec builder).
- **the trivial-params pin**: `{}` parses, `Socks5Params::as_json()` == - **the trivial-params pin**: `{}` parses, `Socks5Params::as_json()` ==
`{}`, and unknown fields always reject (the loud-rejection posture — `{}`, and unknown fields always reject (the loud-rejection posture:
the serde pin and the schema's `additionalProperties: false` agree). the serde pin and the schema's `additionalProperties: false` agree).
- **the spec is a constant**: `socks5_open_spec()`'s wire-visible - **the spec is a constant**: `socks5_open_spec()`'s wire-visible
fields (op id, ALPN, scopes, schemas) are identical on every fields (op id, ALPN, scopes, schemas) are identical on every
execution — the open-op surface is wire-stable (ADR-001); the execution; the open-op surface is wire-stable (ADR-001); the
reason-enum table equals the typed parse table (the schema/typed-view reason-enum table equals the typed parse table (the schema/typed-view
drift guard, mirrored from the in-suite test). drift guard, mirrored from the in-suite test).
- **the reason surface is a pass-through extraction, not a validated - **the reason surface is a pass-through extraction, not a validated
table**: `establishment_reason` yields `Some(details.reason)` iff the table**: `establishment_reason` yields `Some(details.reason)` iff the
code is `channel:open_failed` and the reason is a string — any string code is `channel:open_failed` and the reason is a string; any string
is surfaced as-is (the alktunnels harness-model finding #2, adopted is surfaced as-is (the alktunnels harness-model finding #2, adopted
here as the modeled contract); `SocksEstablishmentReason::parse` here as the modeled contract); `SocksEstablishmentReason::parse`
never panics and is total. never panics and is total.
@@ -239,29 +239,29 @@ generator) → `Socks5Params` deserialization + `socks5_open_spec()` +
### 3.4 What is deliberately NOT a target (yet) ### 3.4 What is deliberately NOT a target (yet)
- **The producer pump (`serve_socks5_over`) / consumer sessions as - **The producer pump (`serve_socks5_over`) / consumer sessions as
stateful op-sequence targets** — wave 2, deferred (§4). The stateful op-sequence targets**: wave 2, deferred (§4). The
handshake-level target (§3.2) covers the byte surface; the handshake-level target (§3.2) covers the byte surface; the
session-level interleavings (associate stage select-shape, BIND quiet session-level interleavings (associate stage select-shape, BIND quiet
wait, the two-pump CONNECT plane) are stateful shapes over a live wait, the two-pump CONNECT plane) are stateful shapes over a live
channels stack — the alktunnels wave-2 pattern, worth its own session channels stack (the alktunnels wave-2 pattern), worth its own session
post-0.1. post-0.1.
- **The doors (`door.rs`, `local/`, `composed/`)** — feature-gated - **The doors (`door.rs`, `local/`, `composed/`)** are feature-gated
assembly; their RFC-codec boundary (`parse_udp_request` / assembly; their RFC-codec boundary (`parse_udp_request` /
`new_udp_header`) is covered by the §3.2 target. The rest is I/O `new_udp_header`) is covered by the §3.2 target. The rest is I/O
plumbing over the fuzzed cores, not parse surfaces. plumbing over the fuzzed cores, not parse surfaces.
- **`front_door` RFC 1929 negotiation** — covered by §3.2's auth - **`front_door` RFC 1929 negotiation**: covered by §3.2's auth
sub-handshake invariants (the door drives the same forked parsers). sub-handshake invariants (the door drives the same forked parsers).
## 4. Scope: two waves ## 4. Scope: two waves
**Wave 1 (this session):** the three targets above. All drive **Wave 1 (this session):** the three targets above. All drive
already-`pub` APIs — no `#[cfg(fuzzing)]` exposure needed (matching already-`pub` APIs; no `#[cfg(fuzzing)]` exposure needed (matching
both upstream docs' experience across all eight targets to date). One both upstream docs' experience across all eight targets to date). One
diagnostic addition to the crate: `DatagramReader::retained_bytes()` diagnostic addition to the crate: `DatagramReader::retained_bytes()`
(the decoder-state byte count §3.1's bound asserts against, in (the decoder-state byte count §3.1's bound asserts against, in
alktunnels' `retained_bytes()` style). alktunnels' `retained_bytes()` style).
**Wave 2 (deferred — post-0.1):** `session_pumps`, the stateful **Wave 2 (deferred to post-0.1):** `session_pumps`, the stateful
op-sequence target over the producer pump + consumer sessions in the op-sequence target over the producer pump + consumer sessions in the
alkcall `manager_routing` / alktunnels `session_pumps` style (live alkcall `manager_routing` / alktunnels `session_pumps` style (live
channels stack; ops over CONNECT/ASSOCIATE/BIND interleavings; the channels stack; ops over CONNECT/ASSOCIATE/BIND interleavings; the
@@ -269,7 +269,7 @@ two-pump shutdown-on-completion contract, the egress readiness
contract, and the datagram-stage exit matrix as invariants). Deferred contract, and the datagram-stage exit matrix as invariants). Deferred
because wave 1 covers the wire byte surface completely, the session because wave 1 covers the wire byte surface completely, the session
harness is a large build, and the crate is one commit from its first harness is a large build, and the crate is one commit from its first
publish — the alktunnels sequencing (wave 1 at 0.2.0's fuzz adoption, publish. The alktunnels sequencing (wave 1 at 0.2.0's fuzz adoption,
wave 2 the same session) was possible there because its session shape wave 2 the same session) was possible there because its session shape
was the crate's whole surface; here the byte-level targets carry the was the crate's whole surface; here the byte-level targets carry the
attacker-reachable load and the stateful session target is a separate attacker-reachable load and the stateful session target is a separate
@@ -282,108 +282,108 @@ wave 2 when scheduled.
## 5. Operational isolation: campaigns never share fate with the agent session ## 5. Operational isolation: campaigns never share fate with the agent session
**Adopted verbatim from the upstream docs (alkcall §7.6, alktunnels **Adopted verbatim from the upstream docs (alkcall §7.6, alktunnels
§5)** — an environment constraint, not a nice-to-have. Agent sessions §5)**: an environment constraint, not a nice-to-have. Agent sessions
run fuzzer builds/campaigns through a bash tool that spawns the fuzzer run fuzzer builds/campaigns through a bash tool that spawns the fuzzer
as a child of the session's own process tree and cgroup; an OOM in a as a child of the session's own process tree and cgroup; an OOM in a
fuzz target must cost the fuzzer, never the session. Three layers, all fuzz target must cost the fuzzer, never the session. Three layers, all
mandatory: (1) libFuzzer soft limits (`-rss_limit_mb=2048` mandatory: (1) libFuzzer soft limits (`-rss_limit_mb=2048`
`-malloc_limit_mb=2048`) — never `ulimit -v` under ASAN; (2) fork mode `-malloc_limit_mb=2048`); never `ulimit -v` under ASAN; (2) fork mode
(`-fork=1`) as blast-radius containment (crash/timeout/OOM kills only (`-fork=1`) as blast-radius containment (crash/timeout/OOM kills only
the short-lived child; the harness survives and persists the artifact; the short-lived child; the harness survives and persists the artifact;
also the deadlock answer); (3) process detachment — the runner script also the deadlock answer); (3) process detachment: the runner script
wraps `cargo fuzz run` in `setsid` + `nohup` + full redirection to a wraps `cargo fuzz run` in `setsid` + `nohup` + full redirection to a
log file; poll, never `wait`. log file; poll, never `wait`.
Also inherited: the operational note that `cargo fuzz run` returns Also inherited: the operational note that `cargo fuzz run` returns
before its last fork-mode children fully exit — prefer `pgrep -af` to before its last fork-mode children fully exit; prefer `pgrep -af` to
LIST, kill by explicit pid from the runner's echoed `pid=`, and never LIST, kill by explicit pid from the runner's echoed `pid=`, and never
pattern-kill-and-recheck in one call. pattern-kill-and-recheck in one call.
## 6. No hosted CI ## 6. No hosted CI
Per alkcall §7.9 / alktunnels §6 — the standing policy applies to this Per alkcall §7.9 / alktunnels §6, the standing policy applies to this
repo identically: no hosted CI will exist; do not add Actions/Gitea repo identically: no hosted CI will exist; do not add Actions/Gitea
Actions workflow files. **Corpus replay is the fuzz gate as plain Actions workflow files. **Corpus replay is the fuzz gate as plain
`cargo test`**: `cargo test --manifest-path fuzz/shared/Cargo.toml` `cargo test`**: `cargo test --manifest-path fuzz/shared/Cargo.toml`
replays every committed seed through the invariant functions — add it replays every committed seed through the invariant functions, so add it
to the release verification checklist (AGENTS.md). Campaigns run to the release verification checklist (AGENTS.md). Campaigns run
manually via the detached runner (§6) — e.g. before a release, or after manually via the detached runner (§6); e.g. before a release, or after
touching `src/wire.rs`, `src/rfc1928/`, or `src/params.rs`. touching `src/wire.rs`, `src/rfc1928/`, or `src/params.rs`.
## 7. Implementation log ## 7. Implementation log
- **2026-09-28 — proposal adopted** (this doc). Wave-1 targets - **2026-09-28: proposal adopted** (this doc). Wave-1 targets
`datagram_codec` + `rfc1928_codec` + `params_parse`; wave 2 deferred `datagram_codec` + `rfc1928_codec` + `params_parse`; wave 2 deferred
(§4). (§4).
- **2026-09-28 — `alkcall` bumped 0.8.0 → 0.8.1, - **2026-09-28: `alkcall` bumped 0.8.0 → 0.8.1,
`alktunnels` 0.2.0 → 0.2.1.** Bug-fix releases, no API/wire change; `alktunnels` 0.2.0 → 0.2.1.** Bug-fix releases, no API/wire change;
alkcall's fix (the duplicate adopt/open destroying the live channel) alkcall's fix (the duplicate adopt/open destroying the live channel)
is directly relevant to this crate's consumer sessions — every is directly relevant to this crate's consumer sessions: every
session's teardown reaps via the manager, and the session's teardown reaps via the manager, and the
open-op-response-replayed scenario alkcall's changelog names is the open-op-response-replayed scenario alkcall's changelog names is the
consumer open path. Verification: full suite (default + all-features) consumer open path. Verification: full suite (default + all-features)
green, clippy, fmt, wasm check (§8 commands). green, clippy, fmt, wasm check (§8 commands).
- **2026-09-28 — wave-1 implemented.** The `fuzz/` workspace laid out - **2026-09-28: wave-1 implemented.** The `fuzz/` workspace laid out
per §2 (nightly-pinned subtree, stable `fuzz/shared` library, runner, per §2 (nightly-pinned subtree, stable `fuzz/shared` library, runner,
dict, generator); the three targets over already-`pub` APIs — no dict, generator); the three targets over already-`pub` APIs, with no
`#[cfg(fuzzing)]` exposure needed (the upstream docs' experience, `#[cfg(fuzzing)]` exposure needed (the upstream docs' experience,
now across eleven targets). One diagnostic addition to the crate: now across eleven targets). One diagnostic addition to the crate:
`DatagramReader::retained_bytes()` (§3.1's bound target). Seeds `DatagramReader::retained_bytes()` (§3.1's bound target). Seeds
committed: 45 + 65 + 19 generated (fuzz/gen_fuzz_seeds.py) + 2 committed: 45 + 65 + 19 generated (fuzz/gen_fuzz_seeds.py) + 2
campaign-found crash inputs promoted (`datagram_codec/seed-900`, campaign-found crash inputs promoted (`datagram_codec/seed-900`,
`seed-901` — the §7 harness findings below). `seed-901`, the §7 harness findings below).
- **2026-09-28 — two harness-model defects caught by corpus replay - **2026-09-28: two harness-model defects caught by corpus replay
pre-campaign** (the alkcall §7.7 fuzzer-as-harness-checker pattern): pre-campaign** (the alkcall §7.7 fuzzer-as-harness-checker pattern):
1. **A `len == 0` frame is well-framed but decodes to 1. **A `len == 0` frame is well-framed but decodes to
`Err(EmptyFrame)`** — the re-frame round-trip first expected every `Err(EmptyFrame)`**: the re-frame round-trip first expected every
well-framed frame to decode `Ok`. The frame count still holds over well-framed frame to decode `Ok`. The frame count still holds over
every frame; the byte-for-byte re-frame holds over the decodable every frame; the byte-for-byte re-frame holds over the decodable
ones; `EmptyFrame` is pinned as the only zero-inner-byte shape. ones; `EmptyFrame` is pinned as the only zero-inner-byte shape.
The same arm widened for `ShortAddress`/`DomainNotUtf8`/ The same arm widened for `ShortAddress`/`DomainNotUtf8`/
`BadAtyp`: a well-framed frame can carry any addr-garbage shape — `BadAtyp`: a well-framed frame can carry any addr-garbage shape;
the decode refuses the addr, and the raw frame is the only the decode refuses the addr, and the raw frame is the only
re-frame possible. re-frame possible.
2. **A fieldless `deny_unknown_fields` struct deserializes from ANY 2. **A fieldless `deny_unknown_fields` struct deserializes from ANY
empty JSON composite** (the serde seq form `[]` included) — not empty JSON composite** (the serde seq form `[]` included), not
only `{}`. Not wire-reachable (the registry's `type: object` only `{}`. Not wire-reachable (the registry's `type: object`
input schema is the gate that keeps non-object forms from ever input schema is the gate that keeps non-object forms from ever
reaching the type), but the harness must model the serde reality: reaching the type), but the harness must model the serde reality:
the trivial-params invariant is now "an empty composite parses, the trivial-params invariant is now "an empty composite parses,
everything non-empty rejects, the canonical round-trip is `{}`." everything non-empty rejects, the canonical round-trip is `{}`."
- **2026-09-28 — first campaign finding (fork-mode containment as - **2026-09-28: first campaign finding, fork-mode containment as
designed): a third harness-model defect.** The `datagram_codec` designed (a third harness-model defect).** The `datagram_codec`
campaign crashed at ~35 s on input `0005 0301 1203 03bb 00` campaign crashed at ~35 s on input `0005 0301 1203 03bb 00`
(`BadAtyp(0)` shape) — the re-frame walk's `Err` arm (fixed in the (`BadAtyp(0)` shape); the re-frame walk's `Err` arm (fixed in the
replay pass above) still omitted `BadAtyp`. Fixed; the crash input is replay pass above) still omitted `BadAtyp`. Fixed; the crash input is
committed as `seed-900`; the campaign relaunched clean. committed as `seed-900`; the campaign relaunched clean.
- **2026-09-28 — second campaign finding (also harness, fork-mode - **2026-09-28: second campaign finding (also harness, fork-mode
containment): the count walk's `declared >= 7` gate excluded short containment); the count walk's `declared >= 7` gate excluded short
domains.** Input `0005 0301 1203 03bb 00` — a domain frame with a domains.** Input `0005 0301 1203 03bb 00`, a domain frame with a
1-byte domain (`addr_len = 5`) is a legal empty-payload frame, but 1-byte domain (`addr_len = 5`) is a legal empty-payload frame, but
the walk's `declared >= 7` gate (meant to skip addr-less frames) the walk's `declared >= 7` gate (meant to skip addr-less frames)
rejected it; the per-ATYP `addr_len` computation already does that rejected it; the per-ATYP `addr_len` computation already does that
job. The gate lowered to `>= 4` (the minimum ATYP + length-byte job. The gate lowered to `>= 4` (the minimum ATYP + length-byte
prefix the walk reads); the crash input is committed as `seed-901`; prefix the walk reads); the crash input is committed as `seed-901`;
the campaign relaunched clean. the campaign relaunched clean.
- **2026-09-28 — wave-1 campaigns run (10-min detached runs, §6 - **2026-09-28: wave-1 campaigns run (10-min detached runs, §6
runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048
-timeout=25`; codec `-max_len=65536`, rfc1928 -timeout=25`; codec `-max_len=65536`, rfc1928
`-max_len=4096 -use_value_profile=1`, params `-max_len=4096 -use_value_profile=1`, params
`-dict=json.dict -use_value_profile=1). All three exited 0 with `-dict=json.dict -use_value_profile=1). All three exited 0 with
empty artifact directories — no crashes, hangs, OOMs, or leaks.** empty artifact directories: no crashes, hangs, OOMs, or leaks.**
- `datagram_codec` (post-harness-fixes) — ~1.9M execs (~3k exec/s), - `datagram_codec` (post-harness-fixes), ~1.9M execs (~3k exec/s),
33 fork jobs, `oom/timeout/crash: 0/0/0` throughout. Coverage 33 fork jobs, `oom/timeout/crash: 0/0/0` throughout. Coverage
saturated at 481 edges (ft 2222, corp 209) — the 2-byte-header saturated at 481 edges (ft 2222, corp 209); the 2-byte-header
state machine is fully enumerated (the alktunnels wave-1 state machine is fully enumerated (the alktunnels wave-1
saturation profile). saturation profile).
- `rfc1928_codec` — ~2.0M execs (~3.6k exec/s), 33 fork jobs, - `rfc1928_codec`, ~2.0M execs (~3.6k exec/s), 33 fork jobs,
`oom/timeout/crash: 0/0/0` throughout. Coverage saturated at 1429 `oom/timeout/crash: 0/0/0` throughout. Coverage saturated at 1429
edges (ft 7987, corp 704) — the handshake state machines' edges (ft 7987, corp 704); the handshake state machines'
byte-level shape space is fully enumerated; both protocol byte-level shape space is fully enumerated; both protocol
directions held across every adversarial sequence. directions held across every adversarial sequence.
- `params_parse` — ~3.1M execs (~5.5k exec/s), 33 fork jobs, - `params_parse`, ~3.1M execs (~5.5k exec/s), 33 fork jobs,
`oom/timeout/crash: 0/0/0` throughout. Coverage 1512 edges (ft `oom/timeout/crash: 0/0/0` throughout. Coverage 1512 edges (ft
12970, in-memory corpus 2981 from the 19 committed seeds) — the 12970, in-memory corpus 2981 from the 19 committed seeds); the
serde/untagged shape space was still expanding at budget end; the serde/untagged shape space was still expanding at budget end; the
spec-constant and pass-through-reason invariants held everywhere. spec-constant and pass-through-reason invariants held everywhere.
- The decoder-state bound (§3.1) held everywhere: - The decoder-state bound (§3.1) held everywhere:
@@ -392,13 +392,13 @@ touching `src/wire.rs`, `src/rfc1928/`, or `src/params.rs`.
committed seeds (the §2 policy). committed seeds (the §2 policy).
- **NO crate bugs found by wave 1.** All three findings this session - **NO crate bugs found by wave 1.** All three findings this session
were harness-model defects, each invisible to example-based review were harness-model defects, each invisible to example-based review
and caught by fuzzing/replay — the upstream "fuzzer as a and caught by fuzzing/replay: the upstream "fuzzer as a
harness-checker" pattern, confirmed a third time (alkcall §7.7, harness-checker" pattern, confirmed a third time (alkcall §7.7,
alktunnels §7, here §7). The codec's contracts (infallible alktunnels §7, here §7). The codec's contracts (infallible
reassembly, the F-2 layering, the joint bound, the retained-bytes reassembly, the F-2 layering, the joint bound, the retained-bytes
bound) and the RFC core's no-panic posture held across every bound) and the RFC core's no-panic posture held across every
adversarial input the campaigns generated. adversarial input the campaigns generated.
- (progress appended as implementation proceeds — see git log) - (progress appended as implementation proceeds; see git log)
## 8. Verification commands ## 8. Verification commands
@@ -425,11 +425,11 @@ Campaign flags per target: `datagram_codec -max_len=65536`;
runner, corpus policy, no-CI posture): runner, corpus policy, no-CI posture):
`/workspace/@alkdev/alkcall/docs/research/fuzzing.md`, `/workspace/@alkdev/alkcall/docs/research/fuzzing.md`,
`/workspace/@alkdev/alktunnels/docs/research/fuzzing.md` `/workspace/@alkdev/alktunnels/docs/research/fuzzing.md`
- alkcall 0.8.1 changelog — the finding our consumer open/adopt paths - alkcall 0.8.1 changelog, the finding our consumer open/adopt paths
are exposed to are exposed to
- This crate: ADR-001 (op/params), ADR-003 (the frame codec), ADR-013 - This crate: ADR-001 (op/params), ADR-003 (the frame codec), ADR-013
(the targeted fork), `docs/architecture/wire.md`, (the targeted fork), `docs/architecture/wire.md`,
`docs/architecture/rfc1928.md` `docs/architecture/rfc1928.md`
- External: RUSTSEC-2026-0037 (quinn-proto — the maintainers' - External: RUSTSEC-2026-0037 (quinn-proto, the maintainers'
fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust
Fuzz Book (all linked from the upstream doc's §9) Fuzz Book (all linked from the upstream doc's §9)
+32 -32
View File
@@ -3,7 +3,7 @@ status: complete
last_updated: 2026-09-13 last_updated: 2026-09-13
--- ---
# alksocks research — iroh-socks5 as an alternative base # alksocks research: iroh-socks5 as an alternative base
Evaluation of `iroh-socks5` 0.5.0 (crates.io, published 2026-08-31 by Evaluation of `iroh-socks5` 0.5.0 (crates.io, published 2026-08-31 by
Matthew Geddes) as a potential base for alksocks, in place of or in Matthew Geddes) as a potential base for alksocks, in place of or in
@@ -62,7 +62,7 @@ Module map:
Better than the README's self-deprecation suggests ("LLM-assisted... All Better than the README's self-deprecation suggests ("LLM-assisted... All
bugs are mine"). The wire codecs are clean, bounded, and unit-tested; bugs are mine"). The wire codecs are clean, bounded, and unit-tested;
the integration suite (`tests/integration.rs`, 12 tests under the integration suite (`tests/integration.rs`, 12 tests under
`test-utils`) exercises real iroh connections — direct and via-relay `test-utils`) exercises real iroh connections (direct and via-relay
dials, CONNECT (IP + domain), refused-target error mapping, full UDP dials, CONNECT (IP + domain), refused-target error mapping, full UDP
ASSOCIATE round-trip, BIND, allow-list accept/deny, greeting edge cases. ASSOCIATE round-trip, BIND, allow-list accept/deny, greeting edge cases.
Doc comments are genuinely explanatory. Notable specifics: Doc comments are genuinely explanatory. Notable specifics:
@@ -72,29 +72,29 @@ Doc comments are genuinely explanatory. Notable specifics:
serde (not hand-rolled enums) for the inner types. serde (not hand-rolled enums) for the inner types.
- **UDP header codec** (`socks5.rs` `parse_udp_packet`/ - **UDP header codec** (`socks5.rs` `parse_udp_packet`/
`format_udp_packet`): correct ATYP handling, uses `get(..)?` bounds `format_udp_packet`): correct ATYP handling, uses `get(..)?` bounds
checks, propagates the FRAG byte, domain targets supported — same checks, propagates the FRAG byte, domain targets supported; same
functional slot as fast-socks5's `new_udp_header`/`parse_udp_request`. functional slot as fast-socks5's `new_udp_header`/`parse_udp_request`.
- **Half-close propagation** in `relay_tcp`: each direction signals - **Half-close propagation** in `relay_tcp`: each direction signals
`finish()`/`shutdown()` on completion — a hand-rolled two-pump that `finish()`/`shutdown()` on completion, a hand-rolled two-pump that
*does* shut down the opposite sink (the ADR-050 contract), then joins *does* shut down the opposite sink (the ADR-050 contract), then joins
both halves (`tokio::join!` + propagate both errors). both halves (`tokio::join!` + propagate both errors).
- **UDP association lifecycle**: the client side ends the association - **UDP association lifecycle**: the client side ends the association
when the SOCKS5 control connection closes (`wait_until_closed` when the SOCKS5 control connection closes (`wait_until_closed`
drains-and-detects EOF on the control TCP), not merely when the UDP drains-and-detects EOF on the control TCP), not merely when the UDP
socket goes quiet — the RFC's "TCP control connection closes → socket goes quiet; it implements the RFC's "TCP control connection closes →
association ends" semantic, which many proxies get wrong. association ends" semantic, which many proxies get wrong.
- **Reply-code mapping** is faithful where it matters (refused vs - **Reply-code mapping** is faithful where it matters (refused vs
unreachable distinguishes `ErrorKind::ConnectionRefused`). unreachable distinguishes `ErrorKind::ConnectionRefused`).
Weaknesses (acceptable for its scope, relevant if borrowed): Weaknesses (acceptable for its scope, relevant if borrowed):
- **RFC 1929 username/password is absent** — no-auth only - **RFC 1929 username/password is absent** (no-auth only:
(`negotiate` writes `0xFF` if the client doesn't offer no-auth). Auth `negotiate` writes `0xFF` if the client doesn't offer no-auth). Auth
is the iroh `EndpointId` allow-list at the connection level. is the iroh `EndpointId` allow-list at the connection level.
- **BIND's second-incoming-connection shape** accepts exactly one - **BIND's second-incoming-connection shape** accepts exactly one
inbound connection and has no timeout or multi-accept handling (RFC inbound connection and has no timeout or multi-accept handling (RFC
1928 BIND is notoriously underspecified; this is a reasonable minimal 1928 BIND is notoriously underspecified; this is a reasonable minimal
read, but it binds a real `TcpListener` — the exact thing alksocks' read, but it binds a real `TcpListener`, the exact thing alksocks'
no-bind principle forbids in the protocol layer). no-bind principle forbids in the protocol layer).
- **UDP relay is one socket + flow-table-free on the server side**: all - **UDP relay is one socket + flow-table-free on the server side**: all
datagrams from the association share one `UdpSocket`, replies are datagrams from the association share one `UdpSocket`, replies are
@@ -102,10 +102,10 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
back as `TargetAddr::Ip(from)`). Fine for the common case; no back as `TargetAddr::Ip(from)`). Fine for the common case; no
per-endpoint flow table like alknet's `Socks5UdpSocket` model. per-endpoint flow table like alknet's `Socks5UdpSocket` model.
- **`allowlist_denies_unknown_peer`** closes the QUIC connection without - **`allowlist_denies_unknown_peer`** closes the QUIC connection without
an RFC reply — a vanilla client sees a hard close, not a SOCKS5 error an RFC reply; a vanilla client sees a hard close, not a SOCKS5 error
code. (In alksocks this maps to the typed `channel:open_failed` code. (In alksocks this maps to the typed `channel:open_failed`
establisher refusal, which POC #1 already handles better.) establisher refusal, which POC #1 already handles better.)
- Client-side UDP binds a real socket per association (necessarily — - Client-side UDP binds a real socket per association (necessarily:
it's the front door for vanilla clients); server-side UDP likewise it's the front door for vanilla clients); server-side UDP likewise
binds. binds.
@@ -117,7 +117,7 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
+ `Router::accept(ALPN, handler)` is the same posture as alkcall's + `Router::accept(ALPN, handler)` is the same posture as alkcall's
`register_openable` (ALPN-scoped produced resource, handler per `register_openable` (ALPN-scoped produced resource, handler per
connection). iroh 1.0's `ProtocolHandler` is even *single-method* connection). iroh 1.0's `ProtocolHandler` is even *single-method*
(`accept(Connection)`) — structurally the open-handler shape POC #1 (`accept(Connection)`), structurally the open-handler shape POC #1
used. The crate's ALPN string convention (`iroh-socks5/1`) matches used. The crate's ALPN string convention (`iroh-socks5/1`) matches
the alk/ ALPN convention too. the alk/ ALPN convention too.
2. **One stream per session, no sub-demux.** The `Request`/`Reply` 2. **One stream per session, no sub-demux.** The `Request`/`Reply`
@@ -129,10 +129,10 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
dials), `Client` = consumer half with the optional local TCP front dials), `Client` = consumer half with the optional local TCP front
door (`bind_listener`/`serve` is the ssh `-D` shape POC #1's door (`bind_listener`/`serve` is the ssh `-D` shape POC #1's
`local_front_door` example validated). The *only* consumer path is `local_front_door` example validated). The *only* consumer path is
the local bind — there is no in-band (channels-native) client session the local bind; there is no in-band (channels-native) client session
type, which is where alksocks' wrapper differs. type, which is where alksocks' wrapper differs.
4. **Target resolution on the producing side.** Domains are carried 4. **Target resolution on the producing side.** Domains are carried
unresolved (`TargetAddr::Domain`) and resolved at the exit node — unresolved (`TargetAddr::Domain`) and resolved at the exit node,
the ssh `-D` semantic phase-0 §OQ-SK-05 notes, with a pluggable the ssh `-D` semantic phase-0 §OQ-SK-05 notes, with a pluggable
resolver (`DnsResolver`) and a privacy rationale (DoH so the exit resolver (`DnsResolver`) and a privacy rationale (DoH so the exit
host's network can't observe names) that mirrors the iroh-privacy host's network can't observe names) that mirrors the iroh-privacy
@@ -144,13 +144,13 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
`client.rs` are written against `iroh::endpoint::{Connection, `client.rs` are written against `iroh::endpoint::{Connection,
SendStream, RecvStream}` concretely; `relay.rs` signatures take SendStream, RecvStream}` concretely; `relay.rs` signatures take
`&mut SendStream`. Extracting a substrate-agnostic core would mean `&mut SendStream`. Extracting a substrate-agnostic core would mean
keeping only `protocol.rs` + `socks5.rs` (~550 LoC of codecs) — and keeping only `protocol.rs` + `socks5.rs` (~550 LoC of codecs); and
those are the parts fast-socks5 already provides in stricter, more those are the parts fast-socks5 already provides in stricter, more
complete form (typestate, auth traits, reply-code enums, generic `T`). complete form (typestate, auth traits, reply-code enums, generic `T`).
2. **Not generic over the stream.** The whole reason POC #1 could drop a 2. **Not generic over the stream.** The whole reason POC #1 could drop a
channels `BiStream` into fast-socks5's `Socks5ServerProtocol<T>` with channels `BiStream` into fast-socks5's `Socks5ServerProtocol<T>` with
zero adaptation is fast-socks5's `T: AsyncRead + AsyncWrite + Unpin` zero adaptation is fast-socks5's `T: AsyncRead + AsyncWrite + Unpin`
genericity (AGENTS.md convention 7). iroh-socks5 has no such seam — genericity (AGENTS.md convention 7). iroh-socks5 has no such seam;
the equivalent refactor would be a rewrite, not a fork. the equivalent refactor would be a rewrite, not a fork.
3. **RFC 1928 subset.** No username/password auth (OQ-SK-02's RFC-facing 3. **RFC 1928 subset.** No username/password auth (OQ-SK-02's RFC-facing
path), no auth-method negotiation machinery (fast-socks5's path), no auth-method negotiation machinery (fast-socks5's
@@ -158,13 +158,13 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
(reply codes are bare `u8` consts). fast-socks5 is a conformant (reply codes are bare `u8` consts). fast-socks5 is a conformant
implementation with a test suite covering the RFC edge cases; implementation with a test suite covering the RFC edge cases;
iroh-socks5 is a pragmatic subset. iroh-socks5 is a pragmatic subset.
4. **tokio "full" + iroh pin** (`iroh = "=1.0.3"`, exact pin) — heavy 4. **tokio "full" + iroh pin** (`iroh = "=1.0.3"`, exact pin) is a heavy
dependency for the wasm-clean default-crate invariant (OQ-SK-04); dependency for the wasm-clean default-crate invariant (OQ-SK-04);
the vendored-subset posture would reduce it to the two codec files the vendored-subset posture would reduce it to the two codec files
anyway, at which point fast-socks5's already-POC'd fork (POC #4: anyway, at which point fast-socks5's already-POC'd fork (POC #4:
~120 cfg lines, functional wasm-clean subset) dominates. ~120 cfg lines, functional wasm-clean subset) dominates.
5. **No in-band client session type.** The only consumer path is the 5. **No in-band client session type.** The only consumer path is the
local TCP front door — there is no client half that speaks the local TCP front door; there is no client half that speaks the
framed protocol from inside a process without a kernel listener framed protocol from inside a process without a kernel listener
(fast-socks5's `Socks5Stream::use_stream` slot). alksocks' (fast-socks5's `Socks5Stream::use_stream` slot). alksocks'
consumer half needs exactly that; iroh-socks5 doesn't offer it. consumer half needs exactly that; iroh-socks5 doesn't offer it.
@@ -172,18 +172,18 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
### The valuable part: OQ-SK-03 evidence ### The valuable part: OQ-SK-03 evidence
iroh-socks5 implements channels-native UDP ASSOCIATE without binding a iroh-socks5 implements channels-native UDP ASSOCIATE without binding a
relay-visible port on the producer side beyond the real egress socket — relay-visible port on the producer side beyond the real egress socket;
the exact question OQ-SK-03 poses. Its answers: the exact question OQ-SK-03 poses. Its answers:
- **Datagram stage rides the same stream** (its "second channel" option - **Datagram stage rides the same stream** (its "second channel" option
is not used; the association's stream carries framed `Datagram`s is not used; the association's stream carries framed `Datagram`s
post-reply). Matches the phase-model hunch, including "control stream post-reply). Matches the phase-model hunch, including "control stream
goes silent after the reply" — nothing else is ever sent on the goes silent after the reply": nothing else is ever sent on the
control stream after the ASSOCIATE reply; the client-side relay control stream after the ASSOCIATE reply; the client-side relay
monitors the control connection's EOF (`wait_until_closed`) as the monitors the control connection's EOF (`wait_until_closed`) as the
association's end-of-life, and the RFC's "post-reply control data is association's end-of-life, and the RFC's "post-reply control data is
garbage" semantic holds. garbage" semantic holds.
- **The datagram framing is a length prefix, not the RFC UDP header** — - **The datagram framing is a length prefix, not the RFC UDP header**:
the RFC UDP header (`RSV/FRAG/ATYP/addr/port`) is applied only at the the RFC UDP header (`RSV/FRAG/ATYP/addr/port`) is applied only at the
*front door* (the local UDP socket a vanilla client sends to), where *front door* (the local UDP socket a vanilla client sends to), where
it belongs: `relay_udp_client` parses/`format_udp_packet`s at the it belongs: `relay_udp_client` parses/`format_udp_packet`s at the
@@ -191,22 +191,22 @@ the exact question OQ-SK-03 poses. Its answers:
This is a cleaner cut than phase-0's hunch (which put the length This is a cleaner cut than phase-0's hunch (which put the length
prefix inside the same stream as RFC headers): the alkcall-channel prefix inside the same stream as RFC headers): the alkcall-channel
wire carries `{addr, data}` postcard frames (boundary-preserving by wire carries `{addr, data}` postcard frames (boundary-preserving by
construction — an empty datagram is a frame with empty `data`, never construction (an empty datagram is a frame with empty `data`, never
EOF; alktunnels F-2's requirement is met structurally), and RFC header EOF; alktunnels F-2's requirement is met structurally), and RFC header
encode/decode exists only where a vanilla-client socket boundary encode/decode exists only where a vanilla-client socket boundary
exists (the optional local backends on either side). exists (the optional local backends on either side).
- **Server-side `BND.ADDR` semantics**: the ASSOCIATE reply reports the - **Server-side `BND.ADDR` semantics**: the ASSOCIATE reply reports the
*real* bound UDP socket address (`udp.local_addr()`) — because in *real* bound UDP socket address (`udp.local_addr()`), because in
this design the server-side relay socket is real. On alksocks' this design the server-side relay socket is real. On alksocks'
channels path (no socket), phase-0's sentinel question remains open; channels path (no socket), phase-0's sentinel question remains open;
iroh-socks5 sidesteps it by having the vanilla-facing side be the iroh-socks5 sidesteps it by having the vanilla-facing side be the
*client* half. A producer-side vanilla UDP front door (OQ-SK-03's *client* half. A producer-side vanilla UDP front door (OQ-SK-03's
local-backend-bridge option) would need the same real-socket local-backend-bridge option) would need the same real-socket
treatment; the wrapper-aware in-band path can carry a sentinel or treatment; the wrapper-aware in-band path can carry a sentinel or
(better, per this evidence) no address at all — the framed `Datagram` (better, per this evidence) no address at all; the framed `Datagram`
stream needs no `BND.ADDR` to function; the address is pure stream needs no `BND.ADDR` to function; the address is pure
RFC-compatibility surface. RFC-compatibility surface.
- **No per-endpoint flow table on the producer side** — the relay - **No per-endpoint flow table on the producer side**; the relay
socket is one `send_to`/`recv_from` loop with `TargetAddr` carried socket is one `send_to`/`recv_from` loop with `TargetAddr` carried
per datagram. This is *simpler* than alknet's flow-table model and per datagram. This is *simpler* than alknet's flow-table model and
sufficient: per-datagram addressing in the frame does the sufficient: per-datagram addressing in the frame does the
@@ -224,13 +224,13 @@ the exact question OQ-SK-03 poses. Its answers:
| Stream genericity | `T: AsyncRead + AsyncWrite + Unpin` throughout | concrete iroh `SendStream`/`RecvStream` | | Stream genericity | `T: AsyncRead + AsyncWrite + Unpin` throughout | concrete iroh `SendStream`/`RecvStream` |
| Server structure | explicit typestate + interception points | single `accept()` method, monolithic | | Server structure | explicit typestate + interception points | single `accept()` method, monolithic |
| Auth extension seam | `AuthMethod` trait | none (allow-list only) | | Auth extension seam | `AuthMethod` trait | none (allow-list only) |
| UDP codec | public pure fns (`new_udp_header`, `parse_udp_request`) | public pure fns (`parse_udp_packet`, `format_udp_packet`) — equivalent | | UDP codec | public pure fns (`new_udp_header`, `parse_udp_request`) | public pure fns (`parse_udp_packet`, `format_udp_packet`), equivalent |
| UDP ASSOCIATE over the transport | default handler binds sockets; custom seam narrow (phase-0 caveat) | clean framed-datagram design, no producer-side virtualization needed | | UDP ASSOCIATE over the transport | default handler binds sockets; custom seam narrow (phase-0 caveat) | clean framed-datagram design, no producer-side virtualization needed |
| Producer dial seam | interception points (`run_tcp_proxy`, command-read typestate) | none (dial hardwired in `handle_connection`) | | Producer dial seam | interception points (`run_tcp_proxy`, command-read typestate) | none (dial hardwired in `handle_connection`) |
| Client session genericity | `Socks5Stream::use_stream` (any `T`) | none (local TCP front door only) | | Client session genericity | `Socks5Stream::use_stream` (any `T`) | none (local TCP front door only) |
| wasm posture | fork verified minimal (POC #4) | moot — iroh + tokio-full are native-only | | wasm posture | fork verified minimal (POC #4) | moot (iroh + tokio-full are native-only) |
| Tests | unit + examples; router/custom-auth examples | strong integration suite over real iroh infra | | Tests | unit + examples; router/custom-auth examples | strong integration suite over real iroh infra |
| License | MIT (third-party upstream; inline-vendored — OQ-SK-04) | MIT OR Apache-2.0 | | License | MIT (third-party upstream; inline-vendored, OQ-SK-04) | MIT OR Apache-2.0 |
| LOC | ~4k | ~1.3k | | LOC | ~4k | ~1.3k |
## Verdict ## Verdict
@@ -240,20 +240,20 @@ stand); **adopt iroh-socks5's UDP design as prior art for OQ-SK-03**:
1. The framed `{addr, data}` datagram stream (no RFC header in-band, RFC 1. The framed `{addr, data}` datagram stream (no RFC header in-band, RFC
header only at vanilla front doors) is the cleaner expression of the header only at vanilla front doors) is the cleaner expression of the
phase model — the RFC UDP header becomes a front-door codec concern, phase model: the RFC UDP header becomes a front-door codec concern,
not a channels-wire concern. not a channels-wire concern.
2. Per-datagram addressing with no producer-side flow table suffices 2. Per-datagram addressing with no producer-side flow table suffices
for the *local egress* variant (revisit alknet's flow-table posture for the *local egress* variant (revisit alknet's flow-table posture
in light of this). Caveat added 2026-09-13: under *composed* egress in light of this). Caveat added 2026-09-13: under *composed* egress
(per-target alktunnels udp-tunnels, the OQ-SK-03 handle-swap (per-target alktunnels udp-tunnels, the OQ-SK-03 handle-swap
discussion) the flow table returns as a destination→tunnel-channel discussion) the flow table returns as a destination→tunnel-channel
table — a dial-callback policy, not a protocol-layer concern. table, a dial-callback policy, not a protocol-layer concern.
3. Control-stream EOF ends the association (`wait_until_closed` shape — 3. Control-stream EOF ends the association (`wait_until_closed` shape:
on channels, the channel's EOF is the same signal; no extra control on channels, the channel's EOF is the same signal; no extra control
vocabulary needed). vocabulary needed).
4. FRAG ≠ 0 refused is an acceptable v1 posture. 4. FRAG ≠ 0 refused is an acceptable v1 posture.
Attribution if code is reused (the two codec files are the candidates — Attribution if code is reused (the two codec files are the candidates,
though fast-socks5 already covers them): MIT OR Apache-2.0 dual license, though fast-socks5 already covers them): MIT OR Apache-2.0 dual license,
copyright Matthew Geddes; keep LICENSE-MIT/LICENSE-APACHE entries and a copyright Matthew Geddes; keep LICENSE-MIT/LICENSE-APACHE entries and a
provenance note in any vendored file header. provenance note in any vendored file header.
@@ -261,6 +261,6 @@ provenance note in any vendored file header.
Also worth noting (posture corrected 2026-09-20: fast-socks5 is Also worth noting (posture corrected 2026-09-20: fast-socks5 is
third-party upstream, so nothing is "filed" there as of right; third-party upstream, so nothing is "filed" there as of right;
iroh-socks5 is upstream's own crate): nothing blocking. Optionally a iroh-socks5 is upstream's own crate): nothing blocking. Optionally a
repository link on the crates.io entry would help discoverability — the repository link on the crates.io entry would help discoverability; the
repo exists at `github.com/mattgeddes/iroh-socks5` but `repository` is repo exists at `github.com/mattgeddes/iroh-socks5` but `repository` is
null on crates.io. null on crates.io.
+256 -253
View File
File diff suppressed because it is too large. Load diff
+62 -62
View File
@@ -1,14 +1,14 @@
--- ---
status: complete status: complete
last_updated: 2026-09-20 (ledger statuses updated — the graduation last_updated: 2026-09-20 (ledger statuses updated with the graduation
landed; both alktunnels asks superseded in shape) landed; both alktunnels asks superseded in shape)
--- ---
# alksocks POC findings — channels-native SOCKS5 BIND # alksocks POC findings: channels-native SOCKS5 BIND
POC #5 from phase-0 §OQ-SK-07, run 2026-09-15. Crate: POC #5 from phase-0 §OQ-SK-07, run 2026-09-15. Crate:
`alksocks-bind-poc` (standalone, `/workspace/alksocks-bind-poc/`), against `alksocks-bind-poc` (standalone, `/workspace/alksocks-bind-poc/`), against
alkcall 0.7.1 (crates.io), alktunnels 0.1.0 (crates.io, `local` feature — alkcall 0.7.1 (crates.io), alktunnels 0.1.0 (crates.io, `local` feature;
the listen surface is complete in the published version), the listen surface is complete in the published version),
fast-socks5 1.0.0 (crates.io). 9 tests, all pass; `cargo clippy fast-socks5 1.0.0 (crates.io). 9 tests, all pass; `cargo clippy
--all-targets -- -D warnings` and `cargo fmt --check` clean. --all-targets -- -D warnings` and `cargo fmt --check` clean.
@@ -19,7 +19,7 @@ exactly one inbound accept → reply#2 with the peer address → raw
pass-through data plane); both accept variants behind the accept pass-through data plane); both accept variants behind the accept
callback; the `socks5:bind` scope gate.** fast-socks5 parses the callback; the `socks5:bind` scope gate.** fast-socks5 parses the
command but refuses it (`lib.rs:384-386`, `server.rs:813-816`); the command but refuses it (`lib.rs:384-386`, `server.rs:813-816`); the
wrapper drives the typestate directly and owns the whole BIND shape — wrapper drives the typestate directly and owns the whole BIND shape:
**zero fast-socks5 upstream ask for BIND itself** (the typestate's **zero fast-socks5 upstream ask for BIND itself** (the typestate's
`read_command()` returns `(cmd, target)` and the wrapper branches; `read_command()` returns `(cmd, target)` and the wrapper branches;
`reply_success`'s `sock_addr` parameter is the reply#1 carrier; `reply_success`'s `sock_addr` parameter is the reply#1 carrier;
@@ -27,24 +27,24 @@ wrapper drives the typestate directly and owns the whole BIND shape —
## What was validated ## What was validated
1. **The BIND phase model — confirmed end to end.** The RFC 1. **The BIND phase model was confirmed end to end.** The RFC
conversation and the data plane ride the same channel `BiStream`: conversation and the data plane ride the same channel `BiStream`:
the typestate runs `accept_no_auth → read_command` (BIND arrives the typestate runs `accept_no_auth → read_command` (BIND arrives
as `Socks5Command::TCPBind`), reply#1 carries `BND.ADDR`, the as `Socks5Command::TCPBind`), reply#1 carries `BND.ADDR`, the
quiet wait selects the one inbound accept against the control quiet wait selects the one inbound accept against the control
stream, reply#2 carries the peer address, and `pump_bidi` stream, reply#2 carries the peer address, and `pump_bidi`
(inline — R-02) two-pumps the accepted connection against the (inline, R-02) two-pumps the accepted connection against the
channel. Zero per-packet headers in-band (the CONNECT pass-through channel. Zero per-packet headers in-band; the CONNECT pass-through
conclusion extends literally — BIND's post-reply#2 stage is a conclusion extends literally (BIND's post-reply#2 stage is a
plain byte stream). plain byte stream).
2. **The local accept variant (variant 1, the faithful RFC shape) — 2. **The local accept variant (variant 1, the faithful RFC shape),
confirmed.** A real `TcpListener` per BIND session: reply#1 confirmed.** A real `TcpListener` per BIND session: reply#1
carries the listener's *actual* `local_addr` (a real network-B carries the listener's *actual* `local_addr` (a real network-B
address); the "app server" dials the advertised address; reply#2 address); the "app server" dials the advertised address; reply#2
carries the *real* peer address (`accept()`'s peer, byte-exact — carries the *real* peer address (`accept()`'s peer, byte-exact;
the test asserts the app's ephemeral port); the data plane echoes the test asserts the app's ephemeral port); the data plane echoes
both directions. Vanilla front-door clients work (test 6). both directions. Vanilla front-door clients work (test 6).
3. **The composed accept variant (variant 2) — confirmed, with the 3. **The composed accept variant (variant 2) confirmed, with the
sequencing finding below.** The listener lives on an alktunnels sequencing finding below.** The listener lives on an alktunnels
listen-tunnel resource (`register_tunnel_listen_openable` + listen-tunnel resource (`register_tunnel_listen_openable` +
`AcceptQueue` + a real far-side `TcpListener`); the SOCKS5 `AcceptQueue` + a real far-side `TcpListener`); the SOCKS5
@@ -54,38 +54,38 @@ wrapper drives the typestate directly and owns the whole BIND shape —
`alk/tunnel` listen channel → tunnel producer → real listener → `alk/tunnel` listen channel → tunnel producer → real listener →
the app server's connection → back through the pump. Per-listener the app server's connection → back through the pump. Per-listener
ACL for free (the tunnel resource's ACL governs who can mint the ACL for free (the tunnel resource's ACL governs who can mint the
listener), hub-relayable — the variant's whole point. listener), hub-relayable, the variant's whole point.
4. **The scope gate — confirmed.** The pump handler reads the 4. **The scope gate is confirmed.** The pump handler reads the
open-op `AuthContext`'s identity and requires `socks5:bind` before open-op `AuthContext`'s identity and requires `socks5:bind` before
serving a BIND; an identity without it gets the RFC-faithful serving a BIND; an identity without it gets the RFC-faithful
`ConnectionNotAllowed` (0x02) refusal — while CONNECT still works `ConnectionNotAllowed` (0x02) refusal, while CONNECT still works
for the same identity (the gate distinguishes ingress listeners for the same identity (the gate distinguishes ingress listeners
from proxy egress; one identity having egress does not imply from proxy egress; one identity having egress does not imply
ingress). The gate is the open-op identity seam consumed at ingress). The gate is the open-op identity seam consumed at
command-dispatch time — no new ops, no params change, no command-dispatch time; no new ops, no params change, no
one-way-door exposure. one-way-door exposure.
5. **The quiet wait's semantics — confirmed.** A control byte after 5. **The quiet wait's semantics are confirmed.** A control byte after
reply#1 is protocol garbage (the ASSOCIATE post-reply semantic reply#1 is protocol garbage (the ASSOCIATE post-reply semantic
BIND shares; RFC 1928 §3's "BIND: no further control data" BIND shares; RFC 1928 §3's "BIND: no further control data"
reading); the session is refused with no data plane. A client reading); the session is refused with no data plane. A client
that BINDs and vanishes (EOF mid-wait) tears the listener down that BINDs and vanishes (EOF mid-wait) tears the listener down
with its session — the accept future (which owns the listener) with its session; the accept future (which owns the listener)
is dropped by the `select!` branch; a fresh dial to the is dropped by the `select!` branch; a fresh dial to the
advertised address fails with connection refused. The session's advertised address fails with connection refused. The session's
natural end (half-close post-data-plane) releases the listener natural end (half-close post-data-plane) releases the listener
the same way. the same way.
6. **Failure mapping — confirmed RFC-faithful.** A factory failure 6. **Failure mapping is confirmed RFC-faithful.** A factory failure
(pre-reply#1: an unbindable port, a held port) maps to reply#1 (pre-reply#1: an unbindable port, a held port) maps to reply#1
with the factory error's reply code (shortage → GeneralFailure); with the factory error's reply code (shortage → GeneralFailure);
an accept-stage failure (post-reply#1) maps to reply#2 with the an accept-stage failure (post-reply#1) maps to reply#2 with the
mapped refusal code and no data plane. No phantom listeners, no mapped refusal code and no data plane. No phantom listeners, no
phantom data planes; refusals are typed end to end. phantom data planes; refusals are typed end to end.
7. **One registration, all three commands — regression-guarded.** 7. **One registration, all three commands, regression-guarded.**
CONNECT (POC #1's data plane, unchanged) and UDP ASSOCIATE (the CONNECT (POC #1's data plane, unchanged) and UDP ASSOCIATE (the
POC #2 skeleton) still serve on the same `channels/socks5/sub` op POC #2 skeleton) still serve on the same `channels/socks5/sub` op
after the BIND branch lands. The RFC 1928 command surface is now after the BIND branch lands. The RFC 1928 command surface is now
complete in the crate's shape: CONNECT + BIND + ASSOCIATE. complete in the crate's shape: CONNECT + BIND + ASSOCIATE.
8. **The vanilla front door — confirmed for BIND.** The raw-pipe 8. **The vanilla front door is confirmed for BIND.** The raw-pipe
bridge (POC #1's CONNECT front-door shape, reused verbatim) carries bridge (POC #1's CONNECT front-door shape, reused verbatim) carries
a full vanilla BIND conversation: an in-process RFC 1928 client a full vanilla BIND conversation: an in-process RFC 1928 client
(greeting → BIND command → reply#1 → reply#2 → data plane) through (greeting → BIND command → reply#1 → reply#2 → data plane) through
@@ -96,23 +96,23 @@ wrapper drives the typestate directly and owns the whole BIND shape —
## Structural findings (the load-bearing discoveries) ## Structural findings (the load-bearing discoveries)
**The address-first-vs-accept-first deadlock — the POC's one real **The address-first-vs-accept-first deadlock: the POC's one real
discovery, and the composed variant's sequencing is now pinned.** discovery, and the composed variant's sequencing is now pinned.**
SOCKS5 BIND is address-first (reply#1 must carry `BND.ADDR` *before* SOCKS5 BIND is address-first (reply#1 must carry `BND.ADDR` *before*
the app server can dial), while alktunnels' listen establisher is the app server can dial), while alktunnels' listen establisher is
accept-first (its establishment phase pops an already-accepted accept-first (its establishment phase pops an already-accepted
connection — the open op blocks until one arrives). The first cut connection, so the open op blocks until one arrives). The first cut
opened the listen-tunnel in the factory call (before reply#1) and opened the listen-tunnel in the factory call (before reply#1) and
deadlocked into the establishment timeout: the app server cannot dial deadlocked into the establishment timeout: the app server cannot dial
until reply#1 tells it the address, and the open cannot complete until until reply#1 tells it the address, and the open cannot complete until
it dials. **The resolution: reply#1 first, the tunnel open rides the it dials. **The resolution: reply#1 first, the tunnel open rides the
accept future** — the quiet wait is exactly where the establisher's accept future.** The quiet wait is exactly where the establisher's
accept-pop block belongs. What remains upstream-blocked is *learning accept-pop block belongs. What remains upstream-blocked is *learning
the bound address without consuming an accept* (ledger ask #1): the the bound address without consuming an accept* (ledger ask #1): the
stand-in is the factory's carried `bind_addr` (the expected stand-in is the factory's carried `bind_addr` (the expected
`local_addr` the far-side real listener reports). `local_addr` the far-side real listener reports).
Consequence for the ADR: variant 2 needs TWO upstream asks, not one — Consequence for the ADR: variant 2 needs TWO upstream asks, not one:
(1) listen-addr inspection without consuming an accept (the address (1) listen-addr inspection without consuming an accept (the address
half), and (2) accepted handles carrying peer metadata (the reply#2 half), and (2) accepted handles carrying peer metadata (the reply#2
half). Both are additive surface additions, not changes; the ask half). Both are additive surface additions, not changes; the ask
@@ -120,23 +120,23 @@ ledger below records both with the evidence.
**Establishment-bound interaction (the composed variant's wait **Establishment-bound interaction (the composed variant's wait
bound).** The accept future's tunnel open runs under the listen bound).** The accept future's tunnel open runs under the listen
establisher's default 10s establishment bound — a BIND wait longer establisher's default 10s establishment bound; a BIND wait longer
than that resolves as a refusal (reply#2, GeneralFailure via the than that resolves as a refusal (reply#2, GeneralFailure via the
mapped open error). Correct-by-backstop, but a real BIND deployment mapped open error). Correct-by-backstop, but a real BIND deployment
(FTP control sessions idle far longer than 10s) needs either a raised (FTP control sessions idle far longer than 10s) needs either a raised
bound (the open-op's `timeout` parameter — a registration choice, no bound (the open-op's `timeout` parameter; a registration choice, no
wire change) or the accept future structured so the bound covers only wire change) or the accept future structured so the bound covers only
the open, not the wait (POC note: the bound covered the whole accept the open, not the wait (POC note: the bound covered the whole accept
here; the crate's shape should bound only the open-op round trip). here; the crate's shape should bound only the open-op round trip).
Phase 1 pins this with the registration posture. Phase 1 pins this with the registration posture.
**The teardown-at-birth lesson repeats — and the fix is structural.** **The teardown-at-birth lesson repeats, and the fix is structural.**
The composed variant's first working cut dropped the The composed variant's first working cut dropped the
`TakenHalves::session` (the channel-entry owner) while destructuring `TakenHalves::session` (the channel-entry owner) while destructuring
it — the tunnel EOF'd at birth and the data plane read `early eof` it; the tunnel EOF'd at birth and the data plane read `early eof`
(POC #2's findings predicted exactly this: "the session must live (POC #2's findings predicted exactly this: "the session must live
inside the table entry; the *compiler* should enforce it"). The fix: inside the table entry; the *compiler* should enforce it"). The fix:
`BindTargetHandle` carries `_session: Option<TunnelSession>` — the `BindTargetHandle` carries `_session: Option<TunnelSession>`; the
owner rides the handle and drops with it. This is now the third owner rides the handle and drops with it. This is now the third
witness of the pattern (POC #2's egress table, alktunnels ADR-005's witness of the pattern (POC #2's egress table, alktunnels ADR-005's
teardown contract, here); the crate's Phase 1 shape should make the teardown contract, here); the crate's Phase 1 shape should make the
@@ -144,7 +144,7 @@ plan payload own its channel handles structurally, never by let-binding.
**Peer-address fidelity is lost through the composed path** (ledger **Peer-address fidelity is lost through the composed path** (ledger
ask #2). The popped accept handle (`alktunnels::TargetHandle`) carries ask #2). The popped accept handle (`alktunnels::TargetHandle`) carries
boxed halves only — the real peer address exists at the tunnel boxed halves only; the real peer address exists at the tunnel
producer's listener; in-band the composed variant reports the sentinel producer's listener; in-band the composed variant reports the sentinel
(`0.0.0.0:0`) for reply#2 (the handle-swap reading applied to BIND: (`0.0.0.0:0`) for reply#2 (the handle-swap reading applied to BIND:
the reply is RFC-compatibility surface; the data plane is the the reply is RFC-compatibility surface; the data plane is the
@@ -154,7 +154,7 @@ item #2 is the fidelity ask.
**The quiet wait is `select!`-single-shot, not a loop** (unlike **The quiet wait is `select!`-single-shot, not a loop** (unlike
ASSOCIATE's stage): the accept resolves at most once, and the control ASSOCIATE's stage): the accept resolves at most once, and the control
stream's first byte ends the session (garbage). A loop was stream's first byte ends the session (garbage). A loop was
unnecessary — clippy's `never_loop` caught the first cut's vestigial unnecessary; clippy's `never_loop` caught the first cut's vestigial
`loop`; the phase model's clean statement is that BIND's quiet wait `loop`; the phase model's clean statement is that BIND's quiet wait
has exactly two exits (the accept, or the control stream's has exactly two exits (the accept, or the control stream's
EOF/garbage/error). The data plane starts only from the accept EOF/garbage/error). The data plane starts only from the accept
@@ -164,48 +164,48 @@ branch.
the egress's readiness contract.** The accept future owns the the egress's readiness contract.** The accept future owns the
listener (local variant) or the tunnel-open + popped halves (composed listener (local variant) or the tunnel-open + popped halves (composed
variant); the quiet wait's `select!` drops it on the control-stream variant); the quiet wait's `select!` drops it on the control-stream
branch — that drop IS the client-gone teardown. The crate's Phase 1 branch, and that drop IS the client-gone teardown. The crate's Phase 1
equivalent: the accept callback's future must own its resources equivalent: the accept callback's future must own its resources
end-to-end (the same ownership lesson as POC #2's egress, mirrored). end-to-end (the same ownership lesson as POC #2's egress, mirrored).
## Wire/API notes for the wrapper ## Wire/API notes for the wrapper
- fast-socks5's client `request()` reads exactly one reply — BIND's - fast-socks5's client `request()` reads exactly one reply: BIND's
reply#2 is hand-read with fast-socks5's own public reply#2 is hand-read with fast-socks5's own public
`read_address` codec (reply#1's codec applied again; no second `read_address` codec (reply#1's codec applied again; no second
codec hand-rolled). The consumer's reply#2 reader (`read_reply2`) codec hand-rolled). The consumer's reply#2 reader (`read_reply2`)
is the Phase 1 client-session sketch. is the Phase 1 client-session sketch.
- `Socks5ServerProtocol::reply_success(sock_addr)` is reply#1's - `Socks5ServerProtocol::reply_success(sock_addr)` is reply#1's
carrier — the `sock_addr` parameter carries `BND.ADDR` verbatim carrier: the `sock_addr` parameter carries `BND.ADDR` verbatim
(the local variant's real listener; the composed variant's (the local variant's real listener; the composed variant's
expected far-side address). No upstream change needed. expected far-side address). No upstream change needed.
- fast-socks5's `new_reply` helper is private; the BIND reply#2 - fast-socks5's `new_reply` helper is private; the BIND reply#2
writer hand-rolls the 10-byte reply (VER, REP, RSV, ATYP, addr, writer hand-rolls the 10-byte reply (VER, REP, RSV, ATYP, addr,
port — 10 bytes v4 / 22 v6). Ledger ask #3 (fast-socks5, courtesy): port; 10 bytes v4 / 22 v6). Ledger ask #3 (fast-socks5, courtesy):
publish `new_reply` (or a `reply(code, addr)` constructor) so publish `new_reply` (or a `reply(code, addr)` constructor) so
embedders don't re-encode replies. (2026-09-20: rides the inline embedders don't re-encode replies. (2026-09-20: rides the inline
vendor — see §ledger statuses.) vendor; see §ledger statuses.)
- `ToTargetAddr::to_target_addr()` (not `From`) is the - `ToTargetAddr::to_target_addr()` (not `From`) is the
`SocketAddr → TargetAddr` conversion — `TargetAddr::from` does not `SocketAddr → TargetAddr` conversion; `TargetAddr::from` does not
exist (the POC hit this; the wrapper's accept layer will too). exist (the POC hit this; the wrapper's accept layer will too).
- The listen establisher's default 10s bound covers the composed - The listen establisher's default 10s bound covers the composed
variant's whole accept future in this POC's shape — see the variant's whole accept future in this POC's shape; see the
establishment-bound finding above for the Phase 1 posture. establishment-bound finding above for the Phase 1 posture.
- The scope gate is read from the `OpenHandler`'s `AuthContext` - The scope gate is read from the `OpenHandler`'s `AuthContext`
(CF-005/CF-006's per-call opener identity) — the same seam POC #1 (CF-005/CF-006's per-call opener identity), the same seam POC #1
validated for session-gating, now consumed for a *command-class* validated for session-gating, now consumed for a *command-class*
distinction. `socks5:bind` as a scope string follows the distinction. `socks5:bind` as a scope string follows the
`socks5:open` convention (Phase 1 pins the exact convention). `socks5:open` convention (Phase 1 pins the exact convention).
## Upstream-ask ledger (the one-version alktunnels bump) ## Upstream-ask ledger (the one-version alktunnels bump)
**Ledger statuses (2026-09-20 — the graduation landed).** The user's **Ledger statuses (2026-09-20, the graduation landed).** The user's
standing note held: alktunnels 0.2.0 (ADRs 007/008, over alkcall standing note held: alktunnels 0.2.0 (ADRs 007/008, over alkcall
0.8.0) shipped in one version bump — but adopted the ssh 0.8.0) shipped in one version bump, but adopted the ssh
`forwarded-tcpip`-shaped alternative over the per-ask carriers, so `forwarded-tcpip`-shaped alternative over the per-ask carriers, so
both asks are **superseded in shape**, not implemented as posed: both asks are **superseded in shape**, not implemented as posed:
1. **Listen-addr inspection without consuming an accept — dissolved.** 1. **Listen-addr inspection without consuming an accept: dissolved.**
The bind-first establisher (`bindfirst_establisher`, ADR-008 §1) The bind-first establisher (`bindfirst_establisher`, ADR-008 §1)
binds during establishment, so the open-op reply natively carries binds during establishment, so the open-op reply natively carries
the real OS-chosen bound address (the `bound` reply field via the real OS-chosen bound address (the `bound` reply field via
@@ -214,24 +214,24 @@ both asks are **superseded in shape**, not implemented as posed:
`TunnelListener::bound()`). There is no accept to avoid consuming; `TunnelListener::bound()`). There is no accept to avoid consuming;
this ask's stand-in (the factory's carried `bind_addr`) is retired, this ask's stand-in (the factory's carried `bind_addr`) is retired,
as is the POC's "open rides the accept future" sequencing as is the POC's "open rides the accept future" sequencing
workaround — and the 10s establishment-bound finding dissolves with workaround, and the 10s establishment-bound finding dissolves with
it (the accept wait lives in the pump phase now). The it (the accept wait lives in the pump phase now). The
accept-first surface (`listen_establisher` + `AcceptQueue`) remains accept-first surface (`listen_establisher` + `AcceptQueue`) remains
in 0.2.0 unchanged for the accept-as-open shape. in 0.2.0 unchanged for the accept-as-open shape.
2. **Accepted handles carrying peer metadata — landed at the wire's 2. **Accepted handles carrying peer metadata: landed at the wire's
native moment.** The forwarded op (`channels/tunnel/forwarded`, native moment.** The forwarded op (`channels/tunnel/forwarded`,
ADR-008 §3) carries `{listen, substrate, peer}` params per accept; ADR-008 §3) carries `{listen, substrate, peer}` params per accept;
`TargetHandle` gained no peer field — the graduation chose the `TargetHandle` gained no peer field; the graduation chose the
forwarded-op shape over this item's option-i carrier. Correlation forwarded-op shape over this item's option-i carrier. Correlation
is a producer-minted u32 token (Amendment 1, 2026-09-18), not the is a producer-minted u32 token (Amendment 1, 2026-09-18), not the
originally-planned channel-ID key. originally-planned channel-ID key.
3. **(fast-socks5, courtesy)** `new_reply` is private — publish it 3. **(fast-socks5, courtesy)** `new_reply` is private; publish it
(or a constructor) so embedders don't re-encode the reply wire (or a constructor) so embedders don't re-encode the reply wire
form. Also still open from POC #4: the `net` feature gate and the form. Also still open from POC #4: the `net` feature gate and the
`run_udp_proxy_custom` unconditional-bind seam. (Posture note, `run_udp_proxy_custom` unconditional-bind seam. (Posture note,
2026-09-20: fast-socks5 is third-party upstream and these are no 2026-09-20: fast-socks5 is third-party upstream and these are no
longer asks — all three move into the inline vendor, OQ-SK-04 longer asks; all three move into the inline vendor, OQ-SK-04
resolved. 2026-09-21: under the extraction — ADR-011 §Amendment — resolved. 2026-09-21: under the extraction (ADR-011 §Amendment),
`new_reply` is the one that rides in-tree; the `net` gate and the `new_reply` is the one that rides in-tree; the `net` gate and the
bind seam dissolve with the kernel-net region, which is not bind seam dissolve with the kernel-net region, which is not
copied.) copied.)
@@ -242,7 +242,7 @@ alktunnels 0.1.0; the Phase 1 shape composes variant 2 against 0.2.0
The user's standing note: collect alktunnels additions so one version The user's standing note: collect alktunnels additions so one version
bump ships them all. The POC ran against published alktunnels 0.1.0 bump ships them all. The POC ran against published alktunnels 0.1.0
(no fork needed — the listen surface is complete there), and the asks (no fork needed; the listen surface is complete there), and the asks
are additive surface, discovered in priority order: are additive surface, discovered in priority order:
1. **Listen-addr inspection without consuming an accept** (the 1. **Listen-addr inspection without consuming an accept** (the
@@ -259,13 +259,13 @@ are additive surface, discovered in priority order:
the composed accept's reply#2 reports the sentinel; the RFC's the composed accept's reply#2 reports the sentinel; the RFC's
"reply#2 carries the address of the incoming connection" is "reply#2 carries the address of the incoming connection" is
unfilled on the composed path. Shape: `TargetHandle` gaining an unfilled on the composed path. Shape: `TargetHandle` gaining an
optional `peer: Option<SocketAddr>` (or a sibling optional `peer: Option<SocketAddr>` (or a sibling
`AcceptedHandle`) — additive, no wire change. `AcceptedHandle`); additive, no wire change.
3. **(fast-socks5, courtesy)** `new_reply` is private — publish it 3. **(fast-socks5, courtesy)** `new_reply` is private; publish it
(or a constructor) so embedders don't re-encode the reply wire (or a constructor) so embedders don't re-encode the reply wire
form. Also still open from POC #4: the `net` feature gate and the form. Also still open from POC #4: the `net` feature gate and the
`run_udp_proxy_custom` unconditional-bind seam. (2026-09-21: under `run_udp_proxy_custom` unconditional-bind seam. (2026-09-21: under
the extraction — ADR-011 §Amendment — `new_reply` is the one that the extraction, ADR-011 §Amendment, `new_reply` is the one that
rides in-tree; the `net` gate and the bind seam dissolve with the rides in-tree; the `net` gate and the bind seam dissolve with the
kernel-net region, which is not copied.) kernel-net region, which is not copied.)
@@ -275,7 +275,7 @@ reply#2) are honest interim shapes.
## OQ status after this POC ## OQ status after this POC
- **OQ-SK-08 (BIND)**: POC-validated end to end — the phase model - **OQ-SK-08 (BIND)**: POC-validated end to end: the phase model
extends (reply#1 → quiet wait → accept → reply#2 → pass-through); extends (reply#1 → quiet wait → accept → reply#2 → pass-through);
both accept variants implemented (local faithful + composed both accept variants implemented (local faithful + composed
listen-tunnel); the scope gate (`socks5:bind`) distinguishes listen-tunnel); the scope gate (`socks5:bind`) distinguishes
@@ -283,20 +283,20 @@ reply#2) are honest interim shapes.
*resolved in shape* (reply#1 first, the open rides the accept *resolved in shape* (reply#1 first, the open rides the accept
future) with the two upstream asks pinned as the remaining blockers future) with the two upstream asks pinned as the remaining blockers
for variant 2's full fidelity. Remaining for Phase 1: ship-in-base- for variant 2's full fidelity. Remaining for Phase 1: ship-in-base-
crate + default-accept-variant ADR (the support is additive — old crate + default-accept-variant ADR (the support is additive, old
consumers still get the RFC refusal — so nothing here is a consumers still get the RFC refusal, so nothing here is a
one-way door); the establishment-bound posture; the exact one-way door); the establishment-bound posture; the exact
scope-string convention. scope-string convention.
- **OQ-SK-07**: #1, #2, #4, #5 complete; #3 dropped (transferred with - **OQ-SK-07**: #1, #2, #4, #5 complete; #3 dropped (transferred with
OQ-SK-06). The register is closed for this crate's Phase 0. OQ-SK-06). The register is closed for this crate's Phase 0.
- **OQ-SK-01 (dial policy)**: the accept seam is the dial-callback - **OQ-SK-01 (dial policy)**: the accept seam is the dial-callback
shape's mirror (`BindAcceptFactory` — function-not-trait, boxed shape's mirror (`BindAcceptFactory`: function-not-trait, boxed
halves, one instance per session). The no-trait precedent now holds halves, one instance per session). The no-trait precedent now holds
for the accept plane too. for the accept plane too.
## POC placement ## POC placement
Standalone crate (global workspace, `/workspace/alksocks-bind-poc/`) Standalone crate (global workspace, `/workspace/alksocks-bind-poc/`)
per the placement conventions — it composes three published crates per the placement conventions; it composes three published crates
(alkcall 0.7.1, alktunnels 0.1.0, fast-socks5 1.0.0) and needs no code (alkcall 0.7.1, alktunnels 0.1.0, fast-socks5 1.0.0) and needs no code
from this repo. Findings land here; the crate is the artifact. from this repo. Findings land here; the crate is the artifact.
+34 -34
View File
@@ -3,7 +3,7 @@ status: complete
last_updated: 2026-09-13 last_updated: 2026-09-13
--- ---
# alksocks POC findings — channels-native CONNECT + fast-socks5 wasm posture # alksocks POC findings: channels-native CONNECT + fast-socks5 wasm posture
POCs #1 and #4 from phase-0 §OQ-SK-07, run 2026-09-13. POCs #1 and #4 from phase-0 §OQ-SK-07, run 2026-09-13.
@@ -13,37 +13,37 @@ POCs #1 and #4 from phase-0 §OQ-SK-07, run 2026-09-13.
fast-socks5 1.0.0 (crates.io). 8 tests + 1 example fast-socks5 1.0.0 (crates.io). 8 tests + 1 example
(`local_front_door`, the curl front door). All pass. (`local_front_door`, the curl front door). All pass.
- **POC #4** (fast-socks5 wasm fork-minimality): `fs5-wasm-poc` - **POC #4** (fast-socks5 wasm fork-minimality): `fs5-wasm-poc`
(scratch crate, kept at `/workspace/alksocks-fs5-wasm-poc/` — not (scratch crate, kept at `/workspace/alksocks-fs5-wasm-poc/` (not
part of this repo; the finding is the artifact). fast-socks5's part of this repo; the finding is the artifact)). fast-socks5's
`src/` vendored, kernel-net pieces gated behind a `net` feature. `src/` vendored, kernel-net pieces gated behind a `net` feature.
## POC #1 — channels-native SOCKS5 CONNECT ## POC #1: channels-native SOCKS5 CONNECT
### What was validated ### What was validated
1. **`BiStream`-as-`T` genericity — confirmed.** The channels 1. **`BiStream`-as-`T` genericity confirmed.** The channels
`BiStream` (`alkcall::core::types`, `Box<dyn AsyncReadWrite + `BiStream` (`alkcall::core::types`, `Box<dyn AsyncReadWrite +
Unpin>`, impls `AsyncRead + AsyncWrite`, is `Unpin` + `Send`) Unpin>`, impls `AsyncRead + AsyncWrite`, is `Unpin` + `Send`)
satisfies fast-socks5's `Socks5ServerProtocol<T>` server typestate satisfies fast-socks5's `Socks5ServerProtocol<T>` server typestate
and `Socks5Stream<S>::use_stream` client path directly. No adapter, and `Socks5Stream<S>::use_stream` client path directly. No adapter,
no wrapper stream, no boxing beyond what `BiStream` already is. no wrapper stream, no boxing beyond what `BiStream` already is.
2. **`register_openable_with_establisher` fit — confirmed.** The 2. **`register_openable_with_establisher` fit confirmed.** The
producer registers `channels/socks5/sub` (ALPN `alk/socks5`, scope producer registers `channels/socks5/sub` (ALPN `alk/socks5`, scope
`socks5:open`) with an establisher + pump handler exactly as `socks5:open`) with an establisher + pump handler exactly as
alktunnels does for `channels/tunnel/sub`. alktunnels does for `channels/tunnel/sub`.
3. **`pump_bidi` as the CONNECT data plane — confirmed.** Post-reply 3. **`pump_bidi` as the CONNECT data plane confirmed.** Post-reply
the handler awaits `pump_bidi(channel_bistream, target_read, the handler awaits `pump_bidi(channel_bistream, target_read,
target_write)` inline; echo round-trips clean through target_write)` inline; echo round-trips clean through
client → channel → pump → target → back. client → channel → pump → target → back.
4. **Session refusal is a typed call error — confirmed.** An identity 4. **Session refusal is a typed call error, confirmed.** An identity
lacking the open scope gets `FORBIDDEN` at the registry ACL gate; lacking the open scope gets `FORBIDDEN` at the registry ACL gate;
`ChannelOpenError::CallFailed` carries it; no phantom channel. `ChannelOpenError::CallFailed` carries it; no phantom channel.
The establisher's per-call identity arrives as expected The establisher's per-call identity arrives as expected
(witnessed `"consumer"`; CF-005/CF-006 seam works). (witnessed `"consumer"`; CF-005/CF-006 seam works).
5. **Dial failure → RFC reply code mapping — confirmed.** The dial 5. **Dial failure → RFC reply code mapping confirmed.** The dial
refusal surfaces to the RFC client as `ConnectionRefused` (0x05) refusal surfaces to the RFC client as `ConnectionRefused` (0x05)
in the CONNECT reply — the faithful per-RFC error mapping in the CONNECT reply, the faithful per-RFC error mapping
AGENTS.md convention 2 requires — with no phantom data plane. AGENTS.md convention 2 requires, with no phantom data plane.
6. **The front door works with a vanilla client (stretch goal).** 6. **The front door works with a vanilla client (stretch goal).**
The consumer-side local exposure shape: bind a real TCP listener, The consumer-side local exposure shape: bind a real TCP listener,
pipe each accepted connection raw onto a channel `BiStream` pipe each accepted connection raw onto a channel `BiStream`
@@ -56,13 +56,13 @@ POCs #1 and #4 from phase-0 §OQ-SK-07, run 2026-09-13.
### Structural findings (the load-bearing discovery) ### Structural findings (the load-bearing discovery)
**The dial cannot live in the establisher — and that is correct, not **The dial cannot live in the establisher, and that is correct, not
a limitation.** The SOCKS5 *target* is unknown at open time: RFC 1928 a limitation.** The SOCKS5 *target* is unknown at open time: RFC 1928
method negotiation and the CONNECT command (with its target address) method negotiation and the CONNECT command (with its target address)
arrive in-stream, after channel establishment. The layering the POC arrive in-stream, after channel establishment. The layering the POC
validates: validates:
- **The establisher is a session gate** — it validates/records the - **The establisher is a session gate**: it validates/records the
opener identity (OQ-SK-02's seam) and does session-level policy. It opener identity (OQ-SK-02's seam) and does session-level policy. It
never sees a target. never sees a target.
- **The dial belongs at the command-read interception point**, inside - **The dial belongs at the command-read interception point**, inside
@@ -81,32 +81,32 @@ natural dialer return type. The no-trait hunch is now evidence-backed.
**Identity propagation (CF-005 (b)) has a producer-side step the **Identity propagation (CF-005 (b)) has a producer-side step the
harness had to do:** the accept-side install hook must call harness had to do:** the accept-side install hook must call
`channel0_conn.set_identity(resolved)` before building the `channel0_conn.set_identity(resolved)` before building the
`CallConnection` — the transport identity does not flow to the `CallConnection`; the transport identity does not flow to the
channel-0 dispatch automatically. (alktunnels' harness does the same; channel-0 dispatch automatically. (alktunnels' harness does the same;
modeled mTLS posture. A real mTLS transport would resolve the peer modeled mTLS posture. A real mTLS transport would resolve the peer
here.) Worth an upstream note: without it, scope-gated opens fail here.) Worth an upstream note: without it, scope-gated opens fail
closed with `FORBIDDEN "authentication required"` — correct behavior, closed with `FORBIDDEN "authentication required"`, correct behavior,
but the wiring requirement is implicit. but the wiring requirement is implicit.
### Wire/API notes for the wrapper ### Wire/API notes for the wrapper
- `TargetAddr::Ip(addr).to_string()` is `ip:port` — when handing the - `TargetAddr::Ip(addr).to_string()` is `ip:port`; when handing the
dial closure a structured target, use `addr.ip().to_string()` + dial closure a structured target, use `addr.ip().to_string()` +
`addr.port()` separately (the POC hit this; the wrapper's target `addr.port()` separately (the POC hit this; the wrapper's target
carrier will too). carrier will too).
- `Socks5Stream::request(cmd, TargetAddr)` — the target must be - `Socks5Stream::request(cmd, TargetAddr)` requires that the target be
constructed via `ToTargetAddr`; IP-string forms parse to IP targets constructed via `ToTargetAddr`; IP-string forms parse to IP targets
(domain form passes through unresolved, per OQ-SK-05's DNS question). (domain form passes through unresolved, per OQ-SK-05's DNS question).
- `SocksServerError::AuthenticationRejected` is a unit variant (no - `SocksServerError::AuthenticationRejected` is a unit variant (no
message field) in 1.0.0. message field) in 1.0.0.
- `Establishment` with no plan (`Establishment::default()`) is the - `Establishment` with no plan (`Establishment::default()`) is the
right shape for SOCKS5 — the dial happens post-establishment, so right shape for SOCKS5; the dial happens post-establishment, so
there is nothing to thread establisher→handler. there is nothing to thread establisher→handler.
- The open-op params are trivially empty (`{}`); the spec's input - The open-op params are trivially empty (`{}`); the spec's input
schema is `{type: object}`. Params stay empty unless a later ADR schema is `{type: object}`. Params stay empty unless a later ADR
adds per-session options. adds per-session options.
## POC #4 — fast-socks5 wasm fork-minimality ## POC #4: fast-socks5 wasm fork-minimality
### Baseline ### Baseline
@@ -117,7 +117,7 @@ but the wiring requirement is implicit.
OQ-SK-04's premise. (alksocks-connect-poc itself re-confirms: the dep OQ-SK-04's premise. (alksocks-connect-poc itself re-confirms: the dep
chain pulls `mio`/`socket2`.) chain pulls `mio`/`socket2`.)
### The gating change — measured, not guessed ### The gating change, measured not guessed
Vendored fast-socks5's `src/` into a scratch crate; feature-gated Vendored fast-socks5's `src/` into a scratch crate; feature-gated
every kernel-net touch behind a `net` feature (default-on, exactly the every kernel-net touch behind a `net` feature (default-on, exactly the
@@ -133,20 +133,20 @@ alktty `local` pattern):
| `Cargo.toml` | `net = ["tokio/net", "dep:socket2", "dep:tokio-stream"]`, default-on | tokio stays `default-features = false, io-util/time/sync/macros` | | `Cargo.toml` | `net = ["tokio/net", "dep:socket2", "dep:tokio-stream"]`, default-on | tokio stays `default-features = false, io-util/time/sync/macros` |
Diff size vs upstream: **~120 changed lines across 5 files, all Diff size vs upstream: **~120 changed lines across 5 files, all
`#[cfg]` insertions** (client.rs 87 diff lines — inflated by the `#[cfg]` insertions** (client.rs 87 diff lines, inflated by the
whitespace of moving the datagram block; server.rs 27; the rest ≤3). whitespace of moving the datagram block; server.rs 27; the rest ≤3).
### Results ### Results
- `cargo check --no-default-features` (native): clean. - `cargo check --no-default-features` (native): clean.
- `cargo check --target wasm32-unknown-unknown --no-default-features`: - `cargo check --target wasm32-unknown-unknown --no-default-features`:
**clean** — no `mio`, no `socket2`, no `tokio::net` in the graph. **clean**: no `mio`, no `socket2`, no `tokio::net` in the graph.
- `cargo test --no-default-features`: a full RFC 1928 CONNECT - `cargo test --no-default-features`: a full RFC 1928 CONNECT
conversation (typestate server over a duplex ↔ `use_stream` client) conversation (typestate server over a duplex ↔ `use_stream` client)
passes — the wasm-clean subset is the *functional* protocol subset, passes; the wasm-clean subset is the *functional* protocol subset,
not a husk. not a husk.
- `cargo check --target wasm32-unknown-unknown` with `net` on: - `cargo check --target wasm32-unknown-unknown` with `net` on:
fails in `mio` (expected — the target gate for wasm users is fails in `mio` (expected; the target gate for wasm users is
`default-features = false`). `default-features = false`).
### Verdict for OQ-SK-04 ### Verdict for OQ-SK-04
@@ -156,24 +156,24 @@ whitespace of moving the datagram block; server.rs 27; the rest ≤3).
insertions and zero code rewrites; the wasm-clean subset still insertions and zero code rewrites; the wasm-clean subset still
implements the whole RFC conversation. tokio itself needs implements the whole RFC conversation. tokio itself needs
`default-features = false` with an explicit `net` opt-in feature `default-features = false` with an explicit `net` opt-in feature
(`net = ["tokio/net", ...]`) — that is the entire shape of the change. (`net = ["tokio/net", ...]`); that is the entire shape of the change.
The one structural discovery: **`tokio_stream` (used for the legacy The one structural discovery: **`tokio_stream` (used for the legacy
`Incoming` stream) and `socket2` must be optional deps keyed to the `Incoming` stream) and `socket2` must be optional deps keyed to the
same feature**, and `try_join!` requires tokio's `macros` feature — same feature**, and `try_join!` requires tokio's `macros` feature;
all mechanical. all mechanical.
Remaining for Case 1: the fork posture is vendoring *into this crate* Remaining for Case 1: the fork posture is vendoring *into this crate*
(per phase-0: vendor the `T`-generic subset, gate the rest), keeping (per phase-0: vendor the `T`-generic subset, gate the rest), keeping
`/workspace/fast-socks5` as the differential reference; the upstream `/workspace/fast-socks5` as the differential reference; the upstream
PR (feature-gate `net` behind a default-on feature) is small enough PR (feature-gate `net` behind a default-on feature) is small enough
to offer as-is. **(2026-09-20: resolved — the inline vendor is the to offer as-is. **(2026-09-20: resolved; the inline vendor is the
decision; the PR is an optional courtesy, never a wait. See phase-0 decision; the PR is an optional courtesy, never a wait. See phase-0
OQ-SK-04.)** OQ-SK-04.)**
### Follow-on changes (posture corrected 2026-09-20: not upstream asks) ### Follow-on changes (posture corrected 2026-09-20: not upstream asks)
**Ownership correction:** fast-socks5 is third-party upstream — we do **Ownership correction:** fast-socks5 is third-party upstream; we do
not own it, and it has not been forked. These are therefore not asks; not own it, and it has not been forked. These are therefore not asks;
they are changes the inline vendor carries in-tree (OQ-SK-04, resolved they are changes the inline vendor carries in-tree (OQ-SK-04, resolved
2026-09-20). The wasm-gate change below is POC #4's; the other two 2026-09-20). The wasm-gate change below is POC #4's; the other two
@@ -182,7 +182,7 @@ into the vendor with it.
1. **Feature-gate kernel-net behind a default-on `net` feature** 1. **Feature-gate kernel-net behind a default-on `net` feature**
(POC #4's change, ~120 lines of `#[cfg]`). (2026-09-21: dissolved (POC #4's change, ~120 lines of `#[cfg]`). (2026-09-21: dissolved
under the extraction — ADR-011 §Amendment; the kernel-net region under the extraction (ADR-011 §Amendment); the kernel-net region
is not copied, and POC #4's gate map survives as the extraction is not copied, and POC #4's gate map survives as the extraction
recipe, not in-tree cfg.) recipe, not in-tree cfg.)
2. **`run_udp_proxy_custom` binds a kernel socket unconditionally** 2. **`run_udp_proxy_custom` binds a kernel socket unconditionally**
@@ -190,24 +190,24 @@ into the vendor with it.
customizable seam covers the relay closure and reply IP only; the customizable seam covers the relay closure and reply IP only; the
channels path must drive the typestate directly. Make the channels path must drive the typestate directly. Make the
`udp_bind_random_port` step optional / accept a caller-supplied `udp_bind_random_port` step optional / accept a caller-supplied
socket. (Both #2 and the POC #5 courtesy item — publish `new_reply` socket. (Both #2 and the POC #5 courtesy item, publish `new_reply`,
— ride the vendor; see `poc-bind-findings.md` §ledger statuses.) ride the vendor; see `poc-bind-findings.md` §ledger statuses.)
## OQ status after these POCs ## OQ status after these POCs
- **OQ-SK-01 (dial policy)**: shape resolved empirically — an injected - **OQ-SK-01 (dial policy)**: shape resolved empirically: an injected
dial callback invoked at the command-read interception point; dial callback invoked at the command-read interception point;
boxed-halves return type; no trait (alktunnels' no-trait precedent boxed-halves return type; no trait (alktunnels' no-trait precedent
holds). Where the callback *comes from* (alktunnels composition vs holds). Where the callback *comes from* (alktunnels composition vs
local TCP vs further hops) stays an assembly-layer question. local TCP vs further hops) stays an assembly-layer question.
- **OQ-SK-02 (auth mapping)**: the seam works — per-call opener - **OQ-SK-02 (auth mapping)**: the seam works; per-call opener
identity arrives at the establisher, and channel-0 identity identity arrives at the establisher, and channel-0 identity
propagation is a wiring requirement the producer harness performs. propagation is a wiring requirement the producer harness performs.
In-band SOCKS auth was not exercised (NoAuth only); the RFC-facing In-band SOCKS auth was not exercised (NoAuth only); the RFC-facing
username/password path for the local backend remains Phase 1. username/password path for the local backend remains Phase 1.
- **OQ-SK-04 (wasm)**: fork-minimality verified; the decision reduced - **OQ-SK-04 (wasm)**: fork-minimality verified; the decision reduced
to "does wasm make sense for alksocks" (the adapter story), with to "does wasm make sense for alksocks" (the adapter story), with
the mechanism de-risked. **(2026-09-20: resolved — inline vendor, the mechanism de-risked. **(2026-09-20: resolved, inline vendor,
on the corrected ownership premise + the BIND/seam needs. See on the corrected ownership premise + the BIND/seam needs. See
phase-0 OQ-SK-04.)** phase-0 OQ-SK-04.)**
- **OQ-SK-07**: #1 and #4 complete; #2 (UDP ASSOCIATE) and #3 (noq) - **OQ-SK-07**: #1 and #4 complete; #2 (UDP ASSOCIATE) and #3 (noq)
+36 -36
View File
@@ -3,7 +3,7 @@ status: complete
last_updated: 2026-09-14 last_updated: 2026-09-14
--- ---
# alksocks POC findings — channels-native UDP ASSOCIATE # alksocks POC findings: channels-native UDP ASSOCIATE
POC #2 from phase-0 §OQ-SK-07, run 2026-09-14. Crate: POC #2 from phase-0 §OQ-SK-07, run 2026-09-14. Crate:
`alksocks-udp-poc` (standalone, `/workspace/alksocks-udp-poc/`), against `alksocks-udp-poc` (standalone, `/workspace/alksocks-udp-poc/`), against
@@ -20,47 +20,47 @@ composed variant's per-destination open cost is measured.
## What was validated ## What was validated
1. **The handle-swap data plane — confirmed end to end.** The 1. **The handle-swap data plane was confirmed end to end.** The
ASSOCIATE conversation and the datagram stage ride the same ASSOCIATE conversation and the datagram stage ride the same
channel `BiStream`. The producer drives the typestate directly channel `BiStream`. The producer drives the typestate directly
(`accept_no_auth → read_command → reply_success(sentinel)`) — (`accept_no_auth → read_command → reply_success(sentinel)`);
`run_udp_proxy_custom` is not used (its unconditional `run_udp_proxy_custom` is not used (its unconditional
`udp_bind_random_port` makes it unusable on channels, per the `udp_bind_random_port` makes it unusable on channels, per the
corrected §Prior art caveat; driving the typestate directly is the corrected §Prior art caveat; driving the typestate directly is the
workaround the upstream ask would retire). workaround the upstream ask would retire).
2. **The in-band frame codec — confirmed.** The wire is alktunnels' 2. **The in-band frame codec is confirmed.** The wire is alktunnels'
boundary-preserving length codec (`frame_datagram`/`DatagramReader` boundary-preserving length codec (`frame_datagram`/`DatagramReader`;
— empty payload legal, never EOF; frames split/batch across channel empty payload legal, never EOF; frames split/batch across channel
chunks transparently) over the RFC address encoding chunks transparently) over the RFC address encoding
(`TargetAddr::to_be_bytes()` — ATYP + addr + port). RSV/FRAG do not (`TargetAddr::to_be_bytes()`: ATYP + addr + port). RSV/FRAG do not
ride the tunnel wire: fragmentation cannot even be expressed ride the tunnel wire: fragmentation cannot even be expressed
in-band; it is refused at front doors (FRAG ≠ 0 → refuse, the in-band; it is refused at front doors (FRAG ≠ 0 → refuse, the
iroh-socks5 v1 posture). The RFC-header codecs iroh-socks5 v1 posture). The RFC-header codecs
(`new_udp_header`/`parse_udp_request`) are used only at the (`new_udp_header`/`parse_udp_request`) are used only at the
front-door boundary, exactly as the baseline specifies. front-door boundary, exactly as the baseline specifies.
3. **Per-datagram addressing, no flow table — confirmed.** One relay 3. **Per-datagram addressing without a flow table was confirmed.** One relay
loop per association; each frame carries its destination; the loop per association; each frame carries its destination; the
local-egress variant relays through one real `UdpSocket` with local-egress variant relays through one real `UdpSocket` with
per-datagram `send_to`/`recv_from` (iroh-socks5's cut). Five per-datagram `send_to`/`recv_from` (iroh-socks5's cut). Five
in-order datagrams round-trip over one association in-order datagrams round-trip over one association
(`udp_multiple_datagrams_one_association`). (`udp_multiple_datagrams_one_association`).
4. **Association lifetime = channel lifetime — confirmed.** The 4. **Association lifetime = channel lifetime, confirmed.** The
consumer's `shutdown()` half-closes the stream; the mux's consumer's `shutdown()` half-closes the stream; the mux's
implicit-EOF sentinel (REQ-CH-01) reaches the producer's stage read implicit-EOF sentinel (REQ-CH-01) reaches the producer's stage read
as EOF; the stage exits and the connection stays healthy (a fresh as EOF; the stage exits and the connection stays healthy (a fresh
associate succeeds immediately after). No extra control vocabulary associate succeeds immediately after). No extra control vocabulary
needed — the channels-level EOF is the association's end-of-life needed; the channels-level EOF is the association's end-of-life
signal, as the iroh-socks5 eval predicted. signal, as the iroh-socks5 eval predicted.
5. **Control silence is structurally enforced — confirmed.** The 5. **Control silence is structurally enforced, confirmed.** The
frame's first byte is ATYP, so any post-reply control byte parses frame's first byte is ATYP, so any post-reply control byte parses
as a garbage ATYP and fails the association loud as a garbage ATYP and fails the association loud
(`udp_undecodable_frame_ends_association`, with the unassigned ATYP (`udp_undecodable_frame_ends_association`, with the unassigned ATYP
0x7F). The phase model needs no explicit mode byte: the reply 0x7F). The phase model needs no explicit mode byte: the reply
itself synchronizes the mode switch. itself synchronizes the mode switch.
6. **Both egress variants — implemented and passing.** 6. **Both egress variants: implemented and passing.**
- **Local egress** (`LocalEgress`): one real UDP socket per - **Local egress** (`LocalEgress`): one real UDP socket per
association; per-datagram addressing; no flow table. Domain-form association; per-datagram addressing; no flow table. Domain-form
destinations resolve at the egress (producer-side DNS — the ssh destinations resolve at the egress (producer-side DNS, the ssh
`-D` semantic, POC-resolved as "the egress owns resolution"). `-D` semantic, POC-resolved as "the egress owns resolution").
- **Composed egress** (`ComposedEgress`): a lazy - **Composed egress** (`ComposedEgress`): a lazy
destination→alktunnels-udp-tunnel table (each new destination destination→alktunnels-udp-tunnel table (each new destination
@@ -80,7 +80,7 @@ composed variant's per-destination open cost is measured.
destination fan-out; the wire space itself is `u32`. The LRU cap + destination fan-out; the wire space itself is `u32`. The LRU cap +
the dedup assertion (`open_count == 1` for repeated same-resource the dedup assertion (`open_count == 1` for repeated same-resource
destinations) are pinned by `udp_composed_egress_lazy_table_lru_and_open_cost`. destinations) are pinned by `udp_composed_egress_lazy_table_lru_and_open_cost`.
8. **The front door — RFC codec composition at the vanilla boundary, 8. **The front door: RFC codec composition at the vanilla boundary,
confirmed.** An in-process bridge exposes the association on a real confirmed.** An in-process bridge exposes the association on a real
UDP socket; the door's pump decodes RFC-header'd datagrams UDP socket; the door's pump decodes RFC-header'd datagrams
(`parse_udp_request`), sends `{addr, data}` frames in-band, and (`parse_udp_request`), sends `{addr, data}` frames in-band, and
@@ -91,14 +91,14 @@ composed variant's per-destination open cost is measured.
shape (the CONNECT front door in POC #1 already validated that shape (the CONNECT front door in POC #1 already validated that
pipe shape); this test pins the UDP-specific claim: RFC codec at pipe shape); this test pins the UDP-specific claim: RFC codec at
the door, frames in-band, zero payload translation. the door, frames in-band, zero payload translation.
9. **CONNECT on the same registration — regression-guarded.** One ALPN 9. **CONNECT on the same registration, regression-guarded.** One ALPN
(`alk/socks5`), one op (`channels/socks5/sub`), trivial params; the (`alk/socks5`), one op (`channels/socks5/sub`), trivial params; the
CONNECT path is the POC #1 data plane unchanged CONNECT path is the POC #1 data plane unchanged
(`connect_still_served_on_the_same_registration`). (`connect_still_served_on_the_same_registration`).
10. **The identity-seam wiring requirement re-confirmed** for the 10. **The identity-seam wiring requirement re-confirmed** for the
composed path: the tunnel producer's channel-0 accept hook must composed path: the tunnel producer's channel-0 accept hook must
set the opener identity (`set_identity`), or scope-gated tunnel set the opener identity (`set_identity`), or scope-gated tunnel
opens fail closed with `FORBIDDEN "authentication required"` — opens fail closed with `FORBIDDEN "authentication required"`;
the same POC #1 finding, now witnessed from the consumer side of the same POC #1 finding, now witnessed from the consumer side of
the composed stack. the composed stack.
@@ -107,22 +107,22 @@ composed variant's per-destination open cost is measured.
**The datagram stage is `select!`-driven, and the egress reply **The datagram stage is `select!`-driven, and the egress reply
future's readiness contract is load-bearing.** The stage runs channel future's readiness contract is load-bearing.** The stage runs channel
reads and the egress's reply stream concurrently (`tokio::select!`). reads and the egress's reply stream concurrently (`tokio::select!`).
Three readiness hazards were hit and fixed in this POC — all of them Three readiness hazards were hit and fixed in this POC, all of them
generalizable API-design notes for the crate's Phase 1: generalizable API-design notes for the crate's Phase 1:
1. **The poll-then-block window loses replies.** The first iteration 1. **The poll-then-block window loses replies.** The first iteration
interleaved "drain egress replies (non-blocking), then block on the interleaved "drain egress replies (non-blocking), then block on the
channel read" — the echo's reply arriving during the channel block channel read"; the echo's reply arriving during the channel block
was never picked up. The fix is the concurrent select shape, which was never picked up. The fix is the concurrent select shape, which
is also the honest full-duplex shape (an egress may produce is also the honest full-duplex shape (an egress may produce
unsolicited inbound datagrams at any time). unsolicited inbound datagrams at any time).
2. **An instantly-ready `Ok(None)` starves the other branch with 2. **An instantly-ready `Ok(None)` starves the other branch with
`biased`.** A `next_reply` that returns `Ok(None)` immediately `biased`.** A `next_reply` that returns `Ok(None)` immediately
(drained) makes the biased select take it every iteration — a (drained) makes the biased select take it every iteration, a
livelock that starves the mux/demux on a current-thread runtime. livelock that starves the mux/demux on a current-thread runtime.
The egress contract is therefore: *`next_reply` must be `Pending` The egress contract is therefore: *`next_reply` must be `Pending`
when no reply is ready* (`Ok(None)` is a spurious wakeup, not a hot when no reply is ready* (`Ok(None)` is a spurious wakeup, not a hot
loop). The `LocalEgress` parks on `readable()` — note `writable()` loop). The `LocalEgress` parks on `readable()`; note `writable()`
is the wrong readiness for UDP (always ready, the send-readiness, is the wrong readiness for UDP (always ready, the send-readiness,
spins forever); `readable()` is the datagram-arrival readiness. spins forever); `readable()` is the datagram-arrival readiness.
The `ComposedEgress` parks on the shared inbound queue's `recv()`; The `ComposedEgress` parks on the shared inbound queue's `recv()`;
@@ -131,7 +131,7 @@ generalizable API-design notes for the crate's Phase 1:
3. **The length framing is applied exactly once, by the wire layer.** 3. **The length framing is applied exactly once, by the wire layer.**
`Egress::next_reply` returns the *inner* bytes (`addr + data`); `Egress::next_reply` returns the *inner* bytes (`addr + data`);
the producer's `write_frame` adds the single length prefix. (The the producer's `write_frame` adds the single length prefix. (The
first iteration framed in the egress and prefixed in the producer — first iteration framed in the egress and prefixed in the producer,
double framing; the client parsed the length field as ATYP 0x00.) double framing; the client parsed the length field as ATYP 0x00.)
**Double framing is the one wire bug the POC caught**, and it **Double framing is the one wire bug the POC caught**, and it
@@ -139,7 +139,7 @@ localizes a Phase 1 API decision: the egress (dial-callback) layer
speaks *inner* bytes; the length codec is the wire layer's. The speaks *inner* bytes; the length codec is the wire layer's. The
consumer side mirrors it: `UdpAssociation::send_datagram` frames once, consumer side mirrors it: `UdpAssociation::send_datagram` frames once,
its `DatagramReader` state (mid-frame position + batched-datagram its `DatagramReader` state (mid-frame position + batched-datagram
pending queue) persists across `recv_datagram` calls — a datagram pending queue) persists across `recv_datagram` calls; a datagram
split across channel chunks survives a caller-driven receive loop split across channel chunks survives a caller-driven receive loop
(the first cut dropped the reader state per call; the echo test's (the first cut dropped the reader state per call; the echo test's
2-byte-prefix chunks exposed it). 2-byte-prefix chunks exposed it).
@@ -150,7 +150,7 @@ drops it (composed tunnels' write halves EOF their channels; the
adopted channel entries are reaped by the sessions' `Drop`, alktunnels adopted channel entries are reaped by the sessions' `Drop`, alktunnels
ADR-005). One structural bug here: the first cut bound the opened ADR-005). One structural bug here: the first cut bound the opened
session to a local `let _session = ...` binding, which dropped at the session to a local `let _session = ...` binding, which dropped at the
end of the insert block — tearing the channel down at birth (the end of the insert block, tearing the channel down at birth (the
relay task saw EOF instantly). The session must live inside the table relay task saw EOF instantly). The session must live inside the table
entry. The crate's Phase 1 equivalent: the egress owns its tunnels entry. The crate's Phase 1 equivalent: the egress owns its tunnels
end-to-end, and the *compiler* should enforce it (no `_` bindings). end-to-end, and the *compiler* should enforce it (no `_` bindings).
@@ -158,13 +158,13 @@ end-to-end, and the *compiler* should enforce it (no `_` bindings).
## Wire/API notes for the wrapper ## Wire/API notes for the wrapper
- `TargetAddr::to_be_bytes()` is the in-band address encoding (ATYP + - `TargetAddr::to_be_bytes()` is the in-band address encoding (ATYP +
addr + port) — the RFC UDP header minus RSV/FRAG. `unframe` decodes addr + port), the RFC UDP header minus RSV/FRAG. `unframe` decodes
it by hand (synchronously over a borrowed slice); it by hand (synchronously over a borrowed slice);
`fast_socks5::util::target_addr::read_address` is the async in-stream `fast_socks5::util::target_addr::read_address` is the async in-stream
equivalent. `frame`/`inner`/`unframe` in the POC's `datagram.rs` are equivalent. `frame`/`inner`/`unframe` in the POC's `datagram.rs` are
the Phase 1 codec's direct sketch. the Phase 1 codec's direct sketch.
- `alktunnels::wire::frame_datagram`/`DatagramReader` are the length - `alktunnels::wire::frame_datagram`/`DatagramReader` are the length
codec, reused unchanged — the shared framing story with codec, reused unchanged, the shared framing story with
alktunnels' udp substrate (per the user's codec decision: depend on alktunnels' udp substrate (per the user's codec decision: depend on
alktunnels, do not hand-roll). alktunnels, do not hand-roll).
- The ASSOCIATE reply's `BND.ADDR` is the sentinel `127.0.0.1:0` on - The ASSOCIATE reply's `BND.ADDR` is the sentinel `127.0.0.1:0` on
@@ -173,14 +173,14 @@ end-to-end, and the *compiler* should enforce it (no `_` bindings).
(`relay_addr`) and never uses it for addressing. Only the front (`relay_addr`) and never uses it for addressing. Only the front
door has a real address to report. door has a real address to report.
- `LocalEgress::next_reply` frames replies with the *sender's* address - `LocalEgress::next_reply` frames replies with the *sender's* address
(the RFC's per-datagram source semantics, `recv_from`'s `from`) — (the RFC's per-datagram source semantics, `recv_from`'s `from`),
not the original destination. For echo targets the two coincide; not the original destination. For echo targets the two coincide;
a real producer would attribute by egress socket (the composed a real producer would attribute by egress socket (the composed
variant's table entry carries the destination explicitly — its variant's table entry carries the destination explicitly; its
replies are attributed correctly by construction). replies are attributed correctly by construction).
- The composed egress's per-destination attribution: the table's - The composed egress's per-destination attribution: the table's
`reply_addr` is recorded at tunnel-open time (the datagram's `reply_addr` is recorded at tunnel-open time (the datagram's
original destination) — the tunnel IS the per-destination original destination); the tunnel IS the per-destination
association, the eval's noted advantage of the composed shape. association, the eval's noted advantage of the composed shape.
- `parse_udp_request` is async (though it completes immediately over a - `parse_udp_request` is async (though it completes immediately over a
borrowed slice); a sync mirror (`unframe`) is used where the decode borrowed slice); a sync mirror (`unframe`) is used where the decode
@@ -191,19 +191,19 @@ end-to-end, and the *compiler* should enforce it (no `_` bindings).
## OQ status after this POC ## OQ status after this POC
- **OQ-SK-03 (UDP ASSOCIATE)**: the handle-swap baseline is - **OQ-SK-03 (UDP ASSOCIATE)**: the handle-swap baseline is
POC-validated end to end — one channel per association, `{addr, POC-validated end to end: one channel per association, `{addr,
data}` frames in-band (RSV/FRAG only at front doors), channel EOF = data}` frames in-band (RSV/FRAG only at front doors), channel EOF =
association lifetime, per-datagram addressing without a producer association lifetime, per-datagram addressing without a producer
flow table. The datagram framing is wire-stable once published — flow table. The datagram framing is wire-stable once published, and
the Phase 1 ADR has empirical ground under it. The demux-vs-phase the Phase 1 ADR has empirical ground under it. The demux-vs-phase
question (the one-way-door extensibility argument) stays an ADR question (the one-way-door extensibility argument) stays an ADR
decision for Phase 1 with the first consumer in sight, but the POC decision for Phase 1 with the first consumer in sight, but the POC
adds evidence for the phase model: no in-band control vocabulary was adds evidence for the phase model: no in-band control vocabulary was
needed, and post-reply garbage is refused structurally. needed, and post-reply garbage is refused structurally.
- **OQ-SK-01 (dial policy)**: the egress seam is the dial-callback - **OQ-SK-01 (dial policy)**: the egress seam is the dial-callback
shape extended to the datagram stage (`EgressFactory` — the `DialFn` shape extended to the datagram stage (`EgressFactory`, the `DialFn`
analogue). Both egress variants behind the callback, the protocol analogue). Both egress variants behind the callback, the protocol
layer sees neither — the no-trait, boxed-halves precedent now holds layer sees neither; the no-trait, boxed-halves precedent now holds
for the datagram plane too. for the datagram plane too.
- **OQ-SK-05 (target policy)**: the composed egress's per-target-ACL- - **OQ-SK-05 (target policy)**: the composed egress's per-target-ACL-
for-free claim is validated (each destination rides its own tunnel for-free claim is validated (each destination rides its own tunnel
@@ -214,6 +214,6 @@ end-to-end, and the *compiler* should enforce it (no `_` bindings).
## POC placement ## POC placement
Standalone crate (global workspace, `/workspace/alksocks-udp-poc/`) Standalone crate (global workspace, `/workspace/alksocks-udp-poc/`)
per the placement conventions — it composes three published crates per the placement conventions; it composes three published crates
(alkcall 0.7.1, alktunnels 0.1.0, fast-socks5 1.0.0) and needs no code (alkcall 0.7.1, alktunnels 0.1.0, fast-socks5 1.0.0) and needs no code
from this repo. Findings land here; the crate is the artifact. from this repo. Findings land here; the crate is the artifact.
+31 -31
View File
@@ -18,7 +18,7 @@ leverages:
4. **Task-Driven**: Structured task graphs with dependency analysis 4. **Task-Driven**: Structured task graphs with dependency analysis
5. **Safe Exit**: Always have a way to unblock progress when stuck 5. **Safe Exit**: Always have a way to unblock progress when stuck
6. **Categorical Estimates**: Use risk/scope/impact categories, not time 6. **Categorical Estimates**: Use risk/scope/impact categories, not time
estimates. These are structurally important — upstream failures multiply estimates. These are structurally important: upstream failures multiply
downstream damage regardless of developer type (human or LLM). See the downstream damage regardless of developer type (human or LLM). See the
cost-benefit framework in taskgraph's framework docs. cost-benefit framework in taskgraph's framework docs.
@@ -47,11 +47,11 @@ approaches
**Process**: **Process**:
1. Architect creates the architecture documentation structure: 1. Architect creates the architecture documentation structure:
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle - `docs/architecture/README.md` (index with doc table, ADR table, lifecycle)
- `docs/architecture/overview.md` — package purpose, exports, dependencies - `docs/architecture/overview.md` (package purpose, exports, dependencies)
- `docs/architecture/<component>.md` — one focused doc per component/area - `docs/architecture/<component>.md` (one focused doc per component/area)
- `docs/architecture/decisions/` — numbered ADR files (ADR-001, ADR-002, ...) - `docs/architecture/decisions/` (numbered ADR files: ADR-001, ADR-002, ...)
- `docs/architecture/open-questions.md` — centralized tracker with OQ-IDs - `docs/architecture/open-questions.md` (centralized tracker with OQ-IDs)
2. All docs start in `Draft` status. Spec docs reference ADRs by number (not 2. All docs start in `Draft` status. Spec docs reference ADRs by number (not
inline rationale) and OQs by number (not inline questions). inline rationale) and OQs by number (not inline questions).
3. Architecture Review validates for ambiguities, risks, and structural issues 3. Architecture Review validates for ambiguities, risks, and structural issues
@@ -134,16 +134,16 @@ WHAT, ADRs explain WHY, open questions track what's unresolved.
- Extract decisions into numbered ADRs in `decisions/` directory - Extract decisions into numbered ADRs in `decisions/` directory
- Centralize open questions in `open-questions.md` with OQ-IDs - Centralize open questions in `open-questions.md` with OQ-IDs
- Maintain `README.md` as the architecture index - Maintain `README.md` as the architecture index
- Keep spec documents focused (~500 lines) — reference ADRs and OQs, don't inline them - Keep spec documents focused (~500 lines); reference ADRs and OQs, don't inline them
- Redirect exploration work to Research Specialist - Redirect exploration work to Research Specialist
- Iterate based on review feedback - Iterate based on review feedback
**Deliverables**: **Deliverables**:
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle - `docs/architecture/README.md`: index with doc table, ADR table, lifecycle
- `docs/architecture/<component>.md` — focused spec documents - `docs/architecture/<component>.md`: focused spec documents
- `docs/architecture/decisions/` — numbered ADR files - `docs/architecture/decisions/`: numbered ADR files
- `docs/architecture/open-questions.md` — centralized OQ tracker - `docs/architecture/open-questions.md`: centralized OQ tracker
--- ---
@@ -179,17 +179,17 @@ sessions.
**Mode**: Primary (manages worktrees and agent sessions) **Mode**: Primary (manages worktrees and agent sessions)
**Uses**: The `worktree` tool from the **open-coordinator** opencode plugin. **Uses**: The `worktree` tool from the **open-coordinator** opencode plugin.
Single tool with `{action, args}` dispatch. Role is auto-detected — coordinator Single tool with `{action, args}` dispatch. Role is auto-detected: coordinator
sessions get the full operation set, spawned implementation sessions get a sessions get the full operation set, spawned implementation sessions get a
limited set (current, notify, status). No mode toggle required. limited set (current, notify, status). No mode toggle required.
**Tools**: **Tools**:
- `worktree({action, args})` — spawn, sessions, dashboard, message, abort, - `worktree({action, args})`, which dispatches spawn, sessions, dashboard, message, abort,
cleanup cleanup
- Bash (opencode CLI for session interaction) - Bash (opencode CLI for session interaction)
- Read (monitor task files) - Read (monitor task files)
- `memory` / `memory_compact` — context management and session history (via - `memory` / `memory_compact` for context management and session history (via
@alkdev/open-memory, when available) @alkdev/open-memory, when available)
**Key Behaviors**: **Key Behaviors**:
@@ -218,10 +218,10 @@ limited set (current, notify, status). No mode toggle required.
**Tools**: **Tools**:
- Read, Write, Edit, Glob, Grep, Bash - Read, Write, Edit, Glob, Grep, Bash
- `worktree({action: "notify", ...})` — report progress/blockers to coordinator - `worktree({action: "notify", ...})` to report progress/blockers to coordinator
- `worktree({action: "current"})` — verify worktree assignment - `worktree({action: "current"})` to verify worktree assignment
- webSearch (documentation lookup) - webSearch (documentation lookup)
- `memory` / `memory_compact` — context management (via @alkdev/open-memory, - `memory` / `memory_compact` for context management (via @alkdev/open-memory,
when available) when available)
**Key Behaviors**: **Key Behaviors**:
@@ -388,7 +388,7 @@ Implement OAuth2 authentication with provider abstraction.
### Categorical Estimates ### Categorical Estimates
These fields are structurally important, not optional metadata. They power These fields are structurally important, not optional metadata. They power
`taskgraph decompose`, `risk-path`, `critical`, and `bottleneck` — commands that `taskgraph decompose`, `risk-path`, `critical`, and `bottleneck`, commands that
reveal structural problems in the task graph. A task missing `scope`, `risk`, reveal structural problems in the task graph. A task missing `scope`, `risk`,
`impact`, or `level` is a red flag indicating incomplete decomposition. See the `impact`, or `level` is a red flag indicating incomplete decomposition. See the
cost-benefit framework in taskgraph's framework docs for the reasoning. cost-benefit framework in taskgraph's framework docs for the reasoning.
@@ -422,15 +422,15 @@ cost-benefit framework in taskgraph's framework docs for the reasoning.
### Architecture work and the `level` field ### Architecture work and the `level` field
Architecture work uses the same taskgraph format as implementation work — Architecture work uses the same taskgraph format as implementation work;
there is no separate `architecture` level. The `level` enum there is no separate `architecture` level. The `level` enum
(`planning, decomposition, implementation, review, research`) already covers (`planning, decomposition, implementation, review, research`) already covers
architecture activities: architecture activities:
| Architecture activity | `level` | Rationale | | Architecture activity | `level` | Rationale |
|---|---|---| |---|---|---|
| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable — the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. | | Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable: the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. |
| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit — breaking a large architecture area into decideable units. | | Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit (breaking a large architecture area into decideable units). |
| Architecture review pass | `review` | Direct fit. | | Architecture review pass | `review` | Direct fit. |
| Research / POC before a decision | `research` | Direct fit. | | Research / POC before a decision | `research` | Direct fit. |
| Spec writing (overview, component spec) | `implementation` | The spec is the deliverable. | | Spec writing (overview, component spec) | `implementation` | The spec is the deliverable. |
@@ -480,7 +480,7 @@ the deferral has two halves that must stay in sync:
2. **The machine-readable half** (in `tasks/architecture/`): an 2. **The machine-readable half** (in `tasks/architecture/`): an
external-trigger tracker task represents the unblocking condition. It is external-trigger tracker task represents the unblocking condition. It is
tagged `[external-trigger, deferred-oq]`, has `risk: trivial` and tagged `[external-trigger, deferred-oq]`, has `risk: trivial` and
`level: research` (it is not actionable work — it tracks whether the `level: research` (it is not actionable work; it tracks whether the
external condition has arrived), and its `id` is referenced from the OQ's external condition has arrived), and its `id` is referenced from the OQ's
`Blocked on:` text. This is the surface for the task graph: `taskgraph` `Blocked on:` text. This is the surface for the task graph: `taskgraph`
tools can reason about it, and downstream work that depends on the tools can reason about it, and downstream work that depends on the
@@ -490,7 +490,7 @@ When the unblocking condition arrives (a use case materializes, a crate is
specced), the tracker task is marked `completed` and the OQ transitions from specced), the tracker task is marked `completed` and the OQ transitions from
`deferred(scope)` to `open` (or directly to `resolved` if no architecture `deferred(scope)` to `open` (or directly to `resolved` if no architecture
decision remains). The two halves serve different audiences and use one edge decision remains). The two halves serve different audiences and use one edge
type (`depends_on`) — the reverse lookup uses `taskgraph dependents`. Do not type (`depends_on`); the reverse lookup uses `taskgraph dependents`. Do not
add a `blocks:` field; it duplicates the edge and creates a sync hazard. add a `blocks:` field; it duplicates the edge and creates a sync hazard.
## Review Injection ## Review Injection
@@ -509,7 +509,7 @@ Use graph analysis to determine where reviews should happen:
### Current (open-coordinator plugin) ### Current (open-coordinator plugin)
The Coordinator uses the `worktree` tool from the open-coordinator opencode The Coordinator uses the `worktree` tool from the open-coordinator opencode
plugin. It's a single tool with `{action, args}` dispatch — no separate plugin. It's a single tool with `{action, args}` dispatch; there are no separate
enable/toggle steps. Role is auto-detected from session state. enable/toggle steps. Role is auto-detected from session state.
``` ```
@@ -563,13 +563,13 @@ a worktree.
When the open-memory plugin is available alongside open-coordinator, the When the open-memory plugin is available alongside open-coordinator, the
coordinator gains: coordinator gains:
- `memory({tool: "children", args: {sessionId: "..."}})` — view sub-agent - `memory({tool: "children", args: {sessionId: "..."}})`: view sub-agent
sessions spawned from the coordinator sessions spawned from the coordinator
- `memory({tool: "messages", args: {sessionId: "..."}})` — read a spawned - `memory({tool: "messages", args: {sessionId: "..."}})` to read a spawned
session's conversation for debugging session's conversation for debugging
- `memory({tool: "context"})` — check context window usage before long - `memory({tool: "context"})` to check context window usage before long
monitoring sessions monitoring sessions
- `memory_compact()` — proactively compact at natural breakpoints - `memory_compact()` to compact proactively at natural breakpoints
Implementation agents can also use `memory({tool: "context"})` and Implementation agents can also use `memory({tool: "context"})` and
`memory_compact()` to manage their context during long tasks. `memory_compact()` to manage their context during long tasks.
@@ -593,7 +593,7 @@ Once the hub is operational, coordination uses native operations:
``` ```
State moves from in-process tracking to Postgres `mappings` table. The State moves from in-process tracking to Postgres `mappings` table. The
open-coordinator plugin becomes unnecessary — the hub provides the same open-coordinator plugin becomes unnecessary; the hub provides the same
capabilities as server-side operations accessible from any environment. capabilities as server-side operations accessible from any environment.
## Document Structure ## Document Structure
@@ -660,7 +660,7 @@ Accepted | Proposed | Deprecated | Superseded
(Links to related specs and ADRs) (Links to related specs and ADRs)
``` ```
Numbering starts at 001 within each project. ADRs are stable — once Accepted, Numbering starts at 001 within each project. ADRs are stable: once Accepted,
they don't revert. If superseded, mark the old one and create a new one. they don't revert. If superseded, mark the old one and create a new one.
### Open Questions Format ### Open Questions Format
@@ -676,7 +676,7 @@ they don't revert. If superseded, mark the old one and create a new one.
- **Status**: open | resolved | deferred(scope) | partially resolved - **Status**: open | resolved | deferred(scope) | partially resolved
- **Priority**: high | medium | low - **Priority**: high | medium | low
- **Resolution**: (when resolved) - **Resolution**: (when resolved)
- **Blocked on**: (when deferred — concrete dependency that would unblock) - **Blocked on**: (when deferred; the concrete dependency that would unblock)
- **Cross-references**: OQ-NN, ADR-NNN - **Cross-references**: OQ-NN, ADR-NNN
``` ```
+6 -6
View File
@@ -5,19 +5,19 @@ libFuzzer targets for alksocks' wire surfaces (see
## Layout ## Layout
- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers. - `fuzz_targets/`: one binary per target; thin `fuzz_target!` wrappers.
- `shared/` — the invariant logic, as a plain library so normal - `shared/`: the invariant logic, as a plain library so normal
`cargo test` (stable toolchain) can replay the committed corpora `cargo test` (stable toolchain) can replay the committed corpora
through the same invariants (`fuzz/shared/src/*.rs` `#[cfg(test)]` through the same invariants (`fuzz/shared/src/*.rs` `#[cfg(test)]`
modules; quinn's CI pattern). The fuzz binaries are nightly-only; modules; quinn's CI pattern). The fuzz binaries are nightly-only;
the shared crate is stable-clean. the shared crate is stable-clean.
- `corpus/<target>/` — committed hand-made seeds (regenerate with - `corpus/<target>/`: committed hand-made seeds (regenerate with
`python3 fuzz/gen_fuzz_seeds.py` from the repo root). Grown corpora `python3 fuzz/gen_fuzz_seeds.py` from the repo root). Grown corpora
and artifacts are gitignored. and artifacts are gitignored.
- `run-detached.sh` — mandatory runner for agent sessions: wraps - `run-detached.sh`: mandatory runner for agent sessions; wraps
`cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM `cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM
in a target can never take down the agent host (research doc §5). in a target can never take down the agent host (research doc §5).
- `json.dict` — JSON token dictionary for the params target. - `json.dict`: JSON token dictionary for the params target.
## Targets ## Targets
@@ -31,7 +31,7 @@ libFuzzer targets for alksocks' wire surfaces (see
`cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/` `cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/`
(or deeper) as the working directory so rustup selects the pinned (or deeper) as the working directory so rustup selects the pinned
nightly toolchain — the detached runner handles that itself. nightly toolchain; the detached runner handles that itself.
```sh ```sh
# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/) # build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/)
+1 -1
View File
@@ -81,7 +81,7 @@ def datagram_codec_seeds():
# The joint-bound max-domain shape: a legal RFC payload that would # The joint-bound max-domain shape: a legal RFC payload that would
# overflow the u16 (65507 to a 255-char domain = 65766). The frame # overflow the u16 (65507 to a 255-char domain = 65766). The frame
# itself can never carry that length field — encode the closest # itself can never carry that length field, so encode the closest
# wire-legal shape (0xFFFF declared with the max-domain body) for # wire-legal shape (0xFFFF declared with the max-domain body) for
# the decode walk; the encode-side 65766 rejection is pinned in # the decode walk; the encode-side 65766 rejection is pinned in
# the shared invariants. # the shared invariants.
+1 -1
View File
@@ -1,4 +1,4 @@
//! `Arbitrary` support for `serde_json::Value` — a bounded-depth //! `Arbitrary` support for `serde_json::Value`: a bounded-depth
//! recursive generator shared by the semantic targets. Depth and size //! recursive generator shared by the semantic targets. Depth and size
//! bounds keep exec/s high (serde_json's own 128-depth recursion limit //! bounds keep exec/s high (serde_json's own 128-depth recursion limit
//! is the decode-side guard; this generator explores structure). //! is the decode-side guard; this generator explores structure).
+11 -11
View File
@@ -35,7 +35,7 @@ fn probe_addr() -> TargetAddr {
pub fn fuzz_datagram_codec(data: &[u8]) { pub fn fuzz_datagram_codec(data: &[u8]) {
// The infallible reassembly direction: any chunk sequence feeds // The infallible reassembly direction: any chunk sequence feeds
// cleanly. Drive the reader with the whole input and with awkward // cleanly. Drive the reader with the whole input and with awkward
// 3-byte slices — splitting transparency must hold for both. // 3-byte slices; splitting transparency must hold for both.
let mut whole = DatagramReader::new(); let mut whole = DatagramReader::new();
let whole_dgs = whole.feed(data); let whole_dgs = whole.feed(data);
assert_retained_bound(&whole, data.len()); assert_retained_bound(&whole, data.len());
@@ -117,7 +117,7 @@ pub fn fuzz_datagram_codec(data: &[u8]) {
// Re-frame round-trip: for input that is a concatenation of // Re-frame round-trip: for input that is a concatenation of
// complete frames, parsing then re-framing reproduces the frames // complete frames, parsing then re-framing reproduces the frames
// byte-for-byte — except a `len == 0` frame, which is well-framed // byte-for-byte, except for a `len == 0` frame, which is well-framed
// but decodes to `Err(EmptyFrame)` (protocol garbage; it has no // but decodes to `Err(EmptyFrame)` (protocol garbage; it has no
// addr to re-frame). The frame count holds over every frame; the // addr to re-frame). The frame count holds over every frame; the
// byte-for-byte re-frame holds over the decodable ones. // byte-for-byte re-frame holds over the decodable ones.
@@ -136,7 +136,7 @@ pub fn fuzz_datagram_codec(data: &[u8]) {
} }
Err(WireError::EmptyFrame) => { Err(WireError::EmptyFrame) => {
// The frame-walk must agree this is a `len == 0` // The frame-walk must agree this is a `len == 0`
// frame — the only zero-inner-byte shape. // frame: the only zero-inner-byte shape.
let declared = u16::from_be_bytes([frame_bytes[0], frame_bytes[1]]) as usize; let declared = u16::from_be_bytes([frame_bytes[0], frame_bytes[1]]) as usize;
assert_eq!(declared, 0); assert_eq!(declared, 0);
assert_eq!(frame_bytes.len(), 2); assert_eq!(frame_bytes.len(), 2);
@@ -147,7 +147,7 @@ pub fn fuzz_datagram_codec(data: &[u8]) {
// A well-framed frame can decode to the address // A well-framed frame can decode to the address
// garbage shapes: the declared length carried a // garbage shapes: the declared length carried a
// too-short, non-UTF-8, or unknown-ATYP addr // too-short, non-UTF-8, or unknown-ATYP addr
// region. The decode refused the addr — the only // region. The decode refused the addr, so the only
// re-frame possible is the raw frame itself, which // re-frame possible is the raw frame itself, which
// the count/prefix walks above already pin. // the count/prefix walks above already pin.
} }
@@ -159,7 +159,7 @@ pub fn fuzz_datagram_codec(data: &[u8]) {
} }
// Frame direction: the joint bound exactly at the edge, with the // Frame direction: the joint bound exactly at the edge, with the
// sizes echoed — including the 65766 max-domain shape (a legal RFC // sizes echoed, including the 65766 max-domain shape (a legal RFC
// payload that would overflow the u16 length field). // payload that would overflow the u16 length field).
let max_payload = MAX_INNER_LEN - addr_encoding_len(&probe_addr()); let max_payload = MAX_INNER_LEN - addr_encoding_len(&probe_addr());
let framed = frame(&probe_addr(), &vec![0u8; max_payload]).expect("exact limit is legal"); let framed = frame(&probe_addr(), &vec![0u8; max_payload]).expect("exact limit is legal");
@@ -214,7 +214,7 @@ pub fn fuzz_datagram_codec(data: &[u8]) {
// Mid-frame observability: after the final feed, the reader is // Mid-frame observability: after the final feed, the reader is
// mid-frame exactly when the input ends inside a frame (a partial // mid-frame exactly when the input ends inside a frame (a partial
// frame beyond the 2-byte header; a dangling 1-byte header // frame beyond the 2-byte header; a dangling 1-byte header
// fragment is NOT mid-frame — the header is not yet consumed). // fragment is NOT mid-frame: the header is not yet consumed).
let expected_mid = ends_inside_frame(data, well_framed_len); let expected_mid = ends_inside_frame(data, well_framed_len);
assert_eq!( assert_eq!(
whole.is_mid_frame(), whole.is_mid_frame(),
@@ -236,7 +236,7 @@ fn result_shapes(dgs: &[Result<(TargetAddr, bytes::Bytes), WireError>]) -> Vec<S
.collect() .collect()
} }
/// A cheap digest stand-in: length + first/last bytes — enough to pin /// A cheap digest stand-in: length + first/last bytes, enough to pin
/// equality across the split feeds. /// equality across the split feeds.
fn digest(payload: &[u8]) -> u64 { fn digest(payload: &[u8]) -> u64 {
let mut h = payload.len() as u64; let mut h = payload.len() as u64;
@@ -251,7 +251,7 @@ fn digest(payload: &[u8]) -> u64 {
/// Walk the framing exactly as the decoder does: complete frames from /// Walk the framing exactly as the decoder does: complete frames from
/// the start; the walk stops at the first frame that overruns the /// the start; the walk stops at the first frame that overruns the
/// input. Returns the frames and the byte length they occupy — the /// input. Returns the frames and the byte length they occupy: the
/// remainder (if any) is a partial/trailing region. /// remainder (if any) is a partial/trailing region.
fn split_frames(data: &[u8]) -> (Vec<&[u8]>, usize) { fn split_frames(data: &[u8]) -> (Vec<&[u8]>, usize) {
let mut frames = Vec::new(); let mut frames = Vec::new();
@@ -269,12 +269,12 @@ fn split_frames(data: &[u8]) -> (Vec<&[u8]>, usize) {
} }
/// Count complete, well-formed-address frames whose payload region is /// Count complete, well-formed-address frames whose payload region is
/// empty — the only source an empty-payload datagram may come from /// empty: the only source an empty-payload datagram may come from
/// (the F-2 walk, alktunnels' count_empty_frames shape adapted to the /// (the F-2 walk, alktunnels' count_empty_frames shape adapted to the
/// `(addr, payload)` inner structure: a frame's empty payload needs /// `(addr, payload)` inner structure: a frame's empty payload needs
/// `declared == addr_len`, not `declared == 0`). Domain-form frames /// `declared == addr_len`, not `declared == 0`). Domain-form frames
/// must also carry a valid-UTF-8 domain: the codec validates UTF-8 /// must also carry a valid-UTF-8 domain: the codec validates UTF-8
/// before the payload split — a non-UTF-8 domain with an empty payload /// before the payload split, so a non-UTF-8 domain with an empty payload
/// region surfaces as `DomainNotUtf8`, never as an empty datagram. /// region surfaces as `DomainNotUtf8`, never as an empty datagram.
fn count_empty_payload_frames(data: &[u8]) -> usize { fn count_empty_payload_frames(data: &[u8]) -> usize {
let mut count = 0usize; let mut count = 0usize;
@@ -315,7 +315,7 @@ fn ends_inside_frame(data: &[u8], well_framed_len: usize) -> bool {
// Mid-frame iff the well-framed prefix leaves a *parsed but // Mid-frame iff the well-framed prefix leaves a *parsed but
// incomplete* frame: a remainder ≥ 2 bytes means the length header // incomplete* frame: a remainder ≥ 2 bytes means the length header
// was consumed and the frame is in flight (a remainder of 1 byte // was consumed and the frame is in flight (a remainder of 1 byte
// is a buffered header fragment — not yet a frame in flight). // is a buffered header fragment, not yet a frame in flight).
let remainder = data.len() - well_framed_len; let remainder = data.len() - well_framed_len;
remainder >= 2 remainder >= 2
} }
+5 -5
View File
@@ -31,12 +31,12 @@ pub fn fuzz_params_parse(data: &[u8]) {
} }
fn parse_shapes(value: &Value) { fn parse_shapes(value: &Value) {
// The trivial params: the canonical form is `{}` — it parses and // The trivial params: the canonical form is `{}`; it parses and
// round-trips to the canonical form. Unknown fields reject loudly // round-trips to the canonical form. Unknown fields reject loudly
// (the serde pin and the schema's additionalProperties agree — // (the serde pin and the schema's additionalProperties agree;
// ADR-001). NOTE the serde reality the harness must model: a // ADR-001). NOTE the serde reality the harness must model: a
// fieldless struct deserializes from ANY empty JSON composite // fieldless struct deserializes from ANY empty JSON composite
// (the seq form `[]` included) — the wire's `type: object` input // (the seq form `[]` included), but the wire's `type: object` input
// schema (the registry's gate) is what keeps the non-object forms // schema (the registry's gate) is what keeps the non-object forms
// from ever reaching the type; the serde pin is the second gate. // from ever reaching the type; the serde pin is the second gate.
let parses = serde_json::from_value::<Socks5Params>(value.clone()).is_ok(); let parses = serde_json::from_value::<Socks5Params>(value.clone()).is_ok();
@@ -54,7 +54,7 @@ fn parse_shapes(value: &Value) {
assert_ne!( assert_ne!(
value, value,
&serde_json::json!({}), &serde_json::json!({}),
"the empty object must parse — anything else must reject" "the empty object must parse; anything else must reject"
); );
} }
@@ -102,7 +102,7 @@ fn parse_shapes(value: &Value) {
// The reason surface is a pass-through extraction, not a validated // The reason surface is a pass-through extraction, not a validated
// table: Some(details.reason) iff the code is channel:open_failed // table: Some(details.reason) iff the code is channel:open_failed
// and the reason is a string — any string surfaced as-is (the // and the reason is a string; any string is surfaced as-is (the
// alktunnels harness-model finding #2, the modeled contract). // alktunnels harness-model finding #2, the modeled contract).
let code = value let code = value
.get("code") .get("code")
+4 -4
View File
@@ -1,7 +1,7 @@
//! Invariants for the forked RFC 1928 core (`src/rfc1928/`, ADR-013 — //! Invariants for the forked RFC 1928 core (`src/rfc1928/`; ADR-013:
//! fully owned code): no-panic on any byte sequence across the whole //! fully owned code): no-panic on any byte sequence across the whole
//! handshake state machine, every malformed shape is a typed error //! handshake state machine, every malformed shape is a typed error
//! (the F-1 class — wire bytes must never reach `unreachable!()`), //! (the F-1 class: wire bytes must never reach `unreachable!()`),
//! round-trips for well-formed messages, and the full-domain //! round-trips for well-formed messages, and the full-domain
//! reply-code table. Both protocol directions are covered: the server //! reply-code table. Both protocol directions are covered: the server
//! typestate (the producer drives it against consumer bytes) and the //! typestate (the producer drives it against consumer bytes) and the
@@ -80,7 +80,7 @@ async fn fuzz_read_address(data: &[u8]) {
} }
} }
Err(err) => { Err(err) => {
// Typed failure — fine; the ATYP gate maps unknown // Typed failure: fine; the ATYP gate maps unknown
// types to IncorrectAddressType (the F-1 class). // types to IncorrectAddressType (the F-1 class).
if let AddrError::IncorrectAddressType = err { if let AddrError::IncorrectAddressType = err {
assert!( assert!(
@@ -191,7 +191,7 @@ async fn fuzz_udp_header(data: &[u8]) {
/// `ReplyError::from_u8` over the full u8 domain: total, no panic, /// `ReplyError::from_u8` over the full u8 domain: total, no panic,
/// unassigned codes map to the GeneralFailure catch-all (the F-1 /// unassigned codes map to the GeneralFailure catch-all (the F-1
/// pin — the direct probe of the class the fork already fixed). /// pin, the direct probe of the class the fork already fixed).
fn fuzz_reply_table(data: &[u8]) { fn fuzz_reply_table(data: &[u8]) {
for &code in data { for &code in data {
let reply = ReplyError::from_u8(code); let reply = ReplyError::from_u8(code);
+143 -134
View File
@@ -1,36 +1,37 @@
//! The `composed` feature's egress + accept providers (ADR-008's //! The `composed` feature's egress + accept providers (ADR-008's
//! composed variants, ADR-009's feature gate): [`direct_egress`] — the //! composed variants, ADR-009's feature gate): [`direct_egress`], the
//! UDP ASSOCIATE egress that dials each destination through an //! UDP ASSOCIATE egress that dials each destination through an
//! alktunnels 0.2.0 direct-op channel //! alktunnels 0.2.0 direct-op channel
//! ([`alktunnels::TunnelSession::open_direct`], `Substrate::Udp`) — //! ([`alktunnels::TunnelSession::open_direct`], `Substrate::Udp`);
//! and [`bindfirst_accept`] — the TCP BIND accept whose listener lives //! and [`bindfirst_accept`], the TCP BIND accept whose listener lives
//! on a bind-first listen resource (ADR-007 variant 2 / ADR-008's //! on a bind-first listen resource (ADR-007 variant 2 / ADR-008's
//! composed accept). //! composed accept).
//! //!
//! **Per-target ACL for free** (ADR-008's composed benefit): each //! **Per-target ACL for free** (ADR-008's composed benefit): each
//! destination rides its own `channels/tunnel/direct`-governed channel //! destination rides its own `channels/tunnel/direct`-governed channel,
//! — the producer's `tunnel:direct` scope gate and dial policy apply //! so the producer's `tunnel:direct` scope gate and dial policy apply
//! per destination, no SOCKS5-layer allowlist needed (ADR-006/ADR-012: //! per destination, with no SOCKS5-layer allowlist needed
//! whatever ACL governs the tunnel capability governs everything //! (ADR-006/ADR-012: whatever ACL governs the tunnel capability
//! reachable through it). //! governs everything reachable through it).
//! //!
//! The shape is POC #2's `ComposedEgress` //! The shape is POC #2's `ComposedEgress`
//! (`/workspace/alksocks-udp-poc/src/egress.rs`), ported to the landed //! (`/workspace/alksocks-udp-poc/src/egress.rs`), ported to the landed
//! seam types: a lazy destination→tunnel table — each new destination //! seam types: a lazy destination→tunnel table, where each new
//! opens one UDP-tunnel channel, a relay task per tunnel drains //! destination opens one UDP-tunnel channel, a relay task per tunnel
//! inbound datagrams into a shared queue, the write half stays in the //! drains inbound datagrams into a shared queue, the write half stays
//! table (LRU-capped; dropping a write half EOFs the tunnel //! in the table (LRU-capped; dropping a write half EOFs the tunnel
//! producer-side; the entry's session drop reaps the channel — //! producer-side; the entry's session drop reaps the channel, which
//! alktunnels ADR-005's teardown contract). //! is alktunnels ADR-005's teardown contract).
//! //!
//! Contracts (the landed seam types' normative docs, honored here): //! Contracts (the landed seam types' normative docs, honored here):
//! //!
//! - **The readiness contract** (load-bearing, `Egress::next_reply`): //! - **The readiness contract** (load-bearing, `Egress::next_reply`):
//! parks on the shared inbound queue (`recv().await` — `Pending` //! parks on the shared inbound queue (`recv().await`, hence
//! until a relay task delivers); a closed queue (all relays ended) //! `Pending` until a relay task delivers); a closed queue (all
//! returns `Err` — never a hot `Ok(None)`. The stage's zero-yield //! relays ended) returns `Err`, never a hot `Ok(None)`. The stage's
//! defense (review 002 R-1) is the backstop, not the design. //! zero-yield defense (review 002 R-1) is the backstop, not the
//! - **Framing-once**: replies carry *inner* bytes (`addr + data` — //! design.
//! - **Framing-once**: replies carry *inner* bytes (`addr + data`;
//! the reply address is the entry's recorded destination, the RFC's //! the reply address is the entry's recorded destination, the RFC's
//! per-datagram source semantics); the length prefix is the wire //! per-datagram source semantics); the length prefix is the wire
//! layer's, applied exactly once downstream. `send` takes the //! layer's, applied exactly once downstream. `send` takes the
@@ -39,34 +40,34 @@
//! wire's. //! wire's.
//! - **Per-datagram `send` is awaited inline** (the stage //! - **Per-datagram `send` is awaited inline** (the stage
//! interleaves sends with reply drains): a slow egress applies //! interleaves sends with reply drains): a slow egress applies
//! backpressure to the association, not to other channels — the //! backpressure to the association, not to other channels, and the
//! batch-2 composed-egress author inherits this bound knowingly //! batch-2 composed-egress author inherits this bound knowingly
//! (wire.md's note). The table's open cost rides the send path //! (wire.md's note). The table's open cost rides the send path
//! (lazy open on first datagram per destination — the POC's //! (lazy open on first datagram per destination; the POC's
//! measured ~0.8 ms). //! measured ~0.8 ms).
//! - **The resource/target mapping**: `open_direct` takes a //! - **The resource/target mapping**: `open_direct` takes a
//! [`SubstrateAddr`] — the destination's [`TargetAddr`] renders to //! [`SubstrateAddr`], which the destination's [`TargetAddr`] renders
//! it (IP forms direct; domain form passes through unresolved — //! to (IP forms direct; domain form passes through unresolved,
//! producer-side resolution, the ssh `-D` semantic; ADR-012). //! producer-side resolution, the ssh `-D` semantic; ADR-012).
//! //!
//! **The Sync-bound wrinkle** (the batch-1 review's seam contract vs //! **The Sync-bound wrinkle** (the batch-1 review's seam contract vs
//! alktunnels' surface): the landed `TargetHandle`/`BindTargetHandle` //! alktunnels' surface): the landed `TargetHandle`/`BindTargetHandle`
//! halves are `Box<dyn AsyncRead + Send + Sync + Unpin>`, but //! halves are `Box<dyn AsyncRead + Send + Sync + Unpin>`, but
//! alktunnels' `TakenHalves` yields `Send + Unpin` only — and //! alktunnels' `TakenHalves` yields `Send + Unpin` only; and
//! `alkcall::BiStream` is `Send + Unpin` but NOT `Sync` (its inner is //! `alkcall::BiStream` is `Send + Unpin` but NOT `Sync` (its inner is
//! a `dyn AsyncReadWrite` trait object). The owner-wrapper posture //! a `dyn AsyncReadWrite` trait object). The owner-wrapper posture
//! (egress.rs's documented contract: "keep-alive state a substrate //! (egress.rs's documented contract: "keep-alive state a substrate
//! needs rides inside the halves — a provider-side owner wrapper") //! needs rides inside the halves, a provider-side owner wrapper")
//! takes two shapes here: [`SessionOwner`] (the generic half-wraps- //! takes two shapes here: [`SessionOwner`] (the generic
//! session wrapper — the egress's design point, exported as the //! half-wraps-session wrapper, the egress's design point, exported as
//! wrapper-aware deployment's surface) and the accept's own //! the wrapper-aware deployment's surface) and the accept's own
//! [`SyncHalves`]/[`OwnedWrite`] adapter pair (a `std::sync::Mutex` //! [`SyncHalves`]/[`OwnedWrite`] adapter pair (a `std::sync::Mutex`
//! around the half — `Mutex<T: Send>` is `Sync`, no unsafe — with //! around the half; `Mutex<T: Send>` is `Sync`, no unsafe; with
//! the channel session owned inside the payload, [`ForwardedOwner`]: //! the channel session owned inside the payload, [`ForwardedOwner`]:
//! dropping the handle drops the session AND the halves together). //! dropping the handle drops the session AND the halves together).
//! For the egress the wrapper is not the handle payload's burden — //! For the egress the wrapper is not the handle payload's burden,
//! the egress owns the session in the table entry (the POC's shape); //! because the egress owns the session in the table entry (the POC's
//! the relay task owns the bare read half. //! shape);
use std::collections::HashMap; use std::collections::HashMap;
use std::net::SocketAddr; use std::net::SocketAddr;
@@ -91,14 +92,14 @@ use crate::egress::{
pub use crate::rfc1928::util::target_addr::TargetAddr; pub use crate::rfc1928::util::target_addr::TargetAddr;
use crate::wire::MAX_INNER_LEN; use crate::wire::MAX_INNER_LEN;
/// The default LRU cap (the 256-channel default cap bounds fan-out — /// The default LRU cap (the 256-channel default cap bounds fan-out,
/// ADR-010's posture; the cap is config, not wire). /// ADR-010's posture; the cap is config, not wire).
pub const DEFAULT_MAX_TUNNELS: usize = 256; pub const DEFAULT_MAX_TUNNELS: usize = 256;
/// The destination's render into the direct op's [`SubstrateAddr`] (the /// The destination's render into the direct op's [`SubstrateAddr`] (the
/// resource/target mapping): IP forms direct; domain form passes /// resource/target mapping): IP forms direct; domain form passes
/// through unresolved (producer-side resolution, the ssh `-D` /// through unresolved (producer-side resolution, the ssh `-D`
/// semantic — ADR-012). /// semantic, per ADR-012).
pub fn substrate_addr_of(addr: &TargetAddr) -> SubstrateAddr { pub fn substrate_addr_of(addr: &TargetAddr) -> SubstrateAddr {
match addr { match addr {
TargetAddr::Ip(sa) => SubstrateAddr::HostPort { TargetAddr::Ip(sa) => SubstrateAddr::HostPort {
@@ -114,7 +115,7 @@ pub fn substrate_addr_of(addr: &TargetAddr) -> SubstrateAddr {
/// Render the destination to the table's key form (the string the LRU /// Render the destination to the table's key form (the string the LRU
/// orders by): `host:port` per [`SubstrateAddr`]'s render (IP literal /// orders by): `host:port` per [`SubstrateAddr`]'s render (IP literal
/// or unresolved domain — the domain stays unresolved, so domain and /// or unresolved domain; the domain stays unresolved, so domain and
/// IP destinations key distinctly, as the client addressed them). /// IP destinations key distinctly, as the client addressed them).
pub fn key_of(addr: &TargetAddr) -> String { pub fn key_of(addr: &TargetAddr) -> String {
match substrate_addr_of(addr) { match substrate_addr_of(addr) {
@@ -124,19 +125,20 @@ pub fn key_of(addr: &TargetAddr) -> String {
} }
/// The owner wrapper (the batch-1 review's seam contract vs /// The owner wrapper (the batch-1 review's seam contract vs
/// alktunnels' surface — the owner-wrapper posture's generic shape; /// alktunnels' surface, providing the owner-wrapper posture's generic
/// shape;
/// the composed accept meets the `+ Sync` bound with its own /// the composed accept meets the `+ Sync` bound with its own
/// [`SyncHalves`]/[`OwnedWrite`] adapter pair instead): an alktunnels /// [`SyncHalves`]/[`OwnedWrite`] adapter pair instead): an alktunnels
/// half wrapped with its [`TunnelSession`] inside. The landed handle /// half wrapped with its [`TunnelSession`] inside. The landed handle
/// payloads carry `+ Sync` (the /// payloads carry `+ Sync` (the
/// `ChannelPlan` payload bound), alktunnels' `TakenHalves` yields /// `ChannelPlan` payload bound), alktunnels' `TakenHalves` yields
/// `Send + Unpin` only — the wrapper owns the session beside the half, /// `Send + Unpin` only; the wrapper owns the session beside the half,
/// so dropping the wrapper drops the half and the session together /// so dropping the wrapper drops the half and the session together
/// (the session's drop reaps the channel entry — alktunnels ADR-005). /// (the session's drop reaps the channel entry, per alktunnels
/// Whether the wrapper is `Sync` (the accept's need) is the half's /// ADR-005). Whether the wrapper is `Sync` (the accept's need) is the
/// own `Sync`ness — asserted by the compile-shape test against the /// half's own `Sync`ness, asserted by the compile-shape test against
/// alktunnels 0.2.0 surface; the egress needs only `Send` (the relay /// the alktunnels 0.2.0 surface; the egress needs only `Send` (the
/// task), which the wrapper always has. /// relay task), which the wrapper always has.
pub struct SessionOwner<H> { pub struct SessionOwner<H> {
half: H, half: H,
session: TunnelSession, session: TunnelSession,
@@ -187,17 +189,17 @@ pub struct ComposedEgress {
order: std::collections::VecDeque<(String, TargetAddr)>, order: std::collections::VecDeque<(String, TargetAddr)>,
inbound: tokio::sync::mpsc::Receiver<Inbound>, inbound: tokio::sync::mpsc::Receiver<Inbound>,
inbound_tx: tokio::sync::mpsc::Sender<Inbound>, inbound_tx: tokio::sync::mpsc::Sender<Inbound>,
/// Live relay tasks — the readiness contract's closed-queue /// Live relay tasks, hence the readiness contract's closed-queue
/// condition: when every relay has ended (each reported via /// condition: when every relay has ended (each reported via
/// [`Inbound::Ended`]) and the queue is drained, `next_reply` /// [`Inbound::Ended`]) and the queue is drained, `next_reply`
/// returns `Err` (the association end), never a hot `Ok(None)`. /// returns `Err` (the association end), never a hot `Ok(None)`.
/// The factory `Sender` alone does not close the queue (the /// The factory `Sender` alone does not close the queue (the
/// egress holds it) — the count is the design. /// egress holds it), so the count is the design.
live_relays: usize, live_relays: usize,
/// Whether any tunnel has ever been opened. The lazy table starts /// Whether any tunnel has ever been opened. The lazy table starts
/// empty: a fresh association's `next_reply` parks until the first /// empty: a fresh association's `next_reply` parks until the first
/// `send` opens a tunnel (the producer's stage selects the channel /// `send` opens a tunnel (the producer's stage selects the channel
/// branch first on a quiescent association — the association /// branch first on a quiescent association, and the association
/// serves until a datagram arrives or the channel EOFs). The /// serves until a datagram arrives or the channel EOFs). The
/// ended-relay `Err` fires only once at least one tunnel existed /// ended-relay `Err` fires only once at least one tunnel existed
/// and every relay has ended (the closed-queue condition). /// and every relay has ended (the closed-queue condition).
@@ -205,7 +207,7 @@ pub struct ComposedEgress {
} }
/// The shared inbound queue's message: a tunnel's datagram (keyed by /// The shared inbound queue's message: a tunnel's datagram (keyed by
/// the table key — the reply address rides the entry) or a relay's /// the table key, the reply address riding the entry) or a relay's
/// end marker (the tunnel's EOF drained). /// end marker (the tunnel's EOF drained).
#[derive(Debug)] #[derive(Debug)]
enum Inbound { enum Inbound {
@@ -214,11 +216,11 @@ enum Inbound {
} }
/// One table entry: the tunnel's write half (the udp substrate's /// One table entry: the tunnel's write half (the udp substrate's
/// framed half — `[len: u16 BE]` frames on the tunnel wire) + the /// framed half, `[len: u16 BE]` frames on the tunnel wire) + the
/// reply address to report for datagrams coming back through it + the /// reply address to report for datagrams coming back through it + the
/// session (the channel-entry owner — the egress owns it, not a /// session (the channel-entry owner; the egress owns it, not a
/// handle payload; dropping the entry EOFs the write half and reaps /// handle payload; dropping the entry EOFs the write half and reaps
/// the channel — alktunnels ADR-005). /// the channel, per alktunnels ADR-005).
struct TableEntry { struct TableEntry {
_session: TunnelSession, _session: TunnelSession,
write: Box<dyn AsyncWrite + Send + Unpin>, write: Box<dyn AsyncWrite + Send + Unpin>,
@@ -229,7 +231,7 @@ struct TableEntry {
/// into the egress's shared inbound queue. Exits on EOF (the tunnel /// into the egress's shared inbound queue. Exits on EOF (the tunnel
/// ended); the write half stays in the table until eviction or stage /// ended); the write half stays in the table until eviction or stage
/// exit. Framing decode is the tunnel codec's (the alktunnels /// exit. Framing decode is the tunnel codec's (the alktunnels
/// ADR-003) — the datagrams here are inner payloads (no RFC address /// ADR-003), so the datagrams here are inner payloads (no RFC address
/// on the tunnel wire; the reply address is the entry's recorded /// on the tunnel wire; the reply address is the entry's recorded
/// destination). /// destination).
async fn relay_read( async fn relay_read(
@@ -255,7 +257,7 @@ async fn relay_read(
let _ = tx.send(Inbound::Ended).await; let _ = tx.send(Inbound::Ended).await;
} }
/// Assemble the reply's inner bytes (`addr + data` — the entry's /// Assemble the reply's inner bytes (`addr + data`, the entry's
/// recorded destination + the payload; framing-once: no length prefix /// recorded destination + the payload; framing-once: no length prefix
/// here, the wire layer's `frame_inner` adds it downstream). The /// here, the wire layer's `frame_inner` adds it downstream). The
/// oversize guard mirrors the wire layer's frame bound. /// oversize guard mirrors the wire layer's frame bound.
@@ -276,10 +278,10 @@ fn inner_bytes(addr: &TargetAddr, payload: &[u8]) -> Result<Bytes, EgressError>
impl ComposedEgress { impl ComposedEgress {
/// Build the egress (the per-association ASSOCIATE seam object). /// Build the egress (the per-association ASSOCIATE seam object).
/// `client` is the channels client the direct ops ride; the /// `client` is the channels client the direct ops ride; the
/// constructor's config carries the LRU cap (`max_tunnels` — the /// constructor's config carries the LRU cap (`max_tunnels`, the
/// fan-out bound, config not wire; the producer's `tunnel:direct` /// fan-out bound, config not wire; the producer's `tunnel:direct`
/// ACL governs which destinations the channels accept — the /// ACL governs which destinations the channels accept, which is
/// per-target-ACL-for-free benefit). /// the per-target-ACL-for-free benefit).
pub fn new(client: Arc<ChannelClient>, max_tunnels: usize) -> Self { pub fn new(client: Arc<ChannelClient>, max_tunnels: usize) -> Self {
let (tx, rx) = tokio::sync::mpsc::channel(64); let (tx, rx) = tokio::sync::mpsc::channel(64);
Self { Self {
@@ -294,7 +296,7 @@ impl ComposedEgress {
} }
} }
/// The entry for `addr` — open lazily on first datagram (the /// The entry for `addr`, opened lazily on first datagram (the
/// open cost rides the send path, per-datagram awaited inline). /// open cost rides the send path, per-datagram awaited inline).
async fn entry_for(&mut self, addr: &TargetAddr) -> Result<&mut TableEntry, EgressError> { async fn entry_for(&mut self, addr: &TargetAddr) -> Result<&mut TableEntry, EgressError> {
let key = key_of(addr); let key = key_of(addr);
@@ -330,7 +332,7 @@ impl ComposedEgress {
self.order.push_back((key.clone(), addr.clone())); self.order.push_back((key.clone(), addr.clone()));
// The entry was inserted above (or the key was already // The entry was inserted above (or the key was already
// present): an absent entry here is a structural invariant // present): an absent entry here is a structural invariant
// break, not a runtime condition — surfacing it as an // break, not a runtime condition; it is surfaced as an
// egress error instead of a panic (convention 2). // egress error instead of a panic (convention 2).
self.entries.get_mut(&key).ok_or_else(|| { self.entries.get_mut(&key).ok_or_else(|| {
EgressError::General(format!( EgressError::General(format!(
@@ -366,7 +368,7 @@ impl Egress for ComposedEgress {
// A non-blocking first pass over the shared queue (the // A non-blocking first pass over the shared queue (the
// relay tasks may have queued replies already), then a // relay tasks may have queued replies already), then a
// real async wait on the queue (`Pending` until a // real async wait on the queue (`Pending` until a
// relay task delivers — the readiness contract). // relay task delivers; this is the readiness contract).
loop { loop {
match self.inbound.try_recv() { match self.inbound.try_recv() {
Ok(Inbound::Datagram(key, dg)) => { Ok(Inbound::Datagram(key, dg)) => {
@@ -376,8 +378,8 @@ impl Egress for ComposedEgress {
}; };
return Ok(Some(inner_bytes(&addr, &dg)?)); return Ok(Some(inner_bytes(&addr, &dg)?));
} }
// A relay ended (its tunnel's EOF drained) — // A relay ended (its tunnel's EOF drained);
// count it; the closed-queue condition is // count it, and the closed-queue condition is
// every relay ended. // every relay ended.
Ok(Inbound::Ended) => { Ok(Inbound::Ended) => {
self.live_relays = self.live_relays.saturating_sub(1); self.live_relays = self.live_relays.saturating_sub(1);
@@ -391,9 +393,10 @@ impl Egress for ComposedEgress {
// Every relay ended (the tunnels ended): a closed // Every relay ended (the tunnels ended): a closed
// relay set must NOT spin the stage's select (an // relay set must NOT spin the stage's select (an
// instantly-ready None would starve the channel // instantly-ready None would starve the channel
// branch) — end the association instead. A fresh // branch), so the stage ends the association
// association (no tunnel ever opened) parks here — // instead. A fresh association (no tunnel ever
// the channel branch governs until the first // opened) parks here, and the channel branch
// governs until the first
// datagram arrives. // datagram arrives.
return Err(EgressError::General("all tunnel relays ended".into())); return Err(EgressError::General("all tunnel relays ended".into()));
} }
@@ -405,7 +408,7 @@ impl Egress for ComposedEgress {
}; };
return Ok(Some(inner_bytes(&addr, &dg)?)); return Ok(Some(inner_bytes(&addr, &dg)?));
} }
// A relay ended while parked — loop back: the // A relay ended while parked; loop back, since the
// count check above decides whether the // count check above decides whether the
// association ends (never a hot `Ok(None)`). // association ends (never a hot `Ok(None)`).
Some(Inbound::Ended) => { Some(Inbound::Ended) => {
@@ -425,8 +428,8 @@ impl Egress for ComposedEgress {
/// producer.md §Wiring): the `EgressFactory` the producer /// producer.md §Wiring): the `EgressFactory` the producer
/// registration consumes. One per ASSOCIATE; the stage owns the /// registration consumes. One per ASSOCIATE; the stage owns the
/// returned egress; stage exit drops it (write halves dropped = EOF /// returned egress; stage exit drops it (write halves dropped = EOF
/// to the tunnel producers; the entries' sessions reap the channels — /// to the tunnel producers; the entries' sessions reap the channels,
/// alktunnels ADR-005's teardown contract). /// per alktunnels ADR-005's teardown contract).
/// ///
/// # Backpressure (inherited knowingly) /// # Backpressure (inherited knowingly)
/// ///
@@ -450,15 +453,16 @@ pub fn direct_egress(client: Arc<ChannelClient>, max_tunnels: usize) -> EgressFa
/// channel's `BiStream` + the forwarded op's `peer` param). One entry /// channel's `BiStream` + the forwarded op's `peer` param). One entry
/// per live BIND session; the forwarded handler (the provider's shared /// per live BIND session; the forwarded handler (the provider's shared
/// opener half) removes and completes the entry on correlation. A /// opener half) removes and completes the entry on correlation. A
/// dropped receiver (client-gone teardown) makes completion fail — the /// dropped receiver (client-gone teardown) makes completion fail, so
/// handler drops the channel stream (teardown-at-birth). /// the handler drops the channel stream (teardown-at-birth).
pub type CorrelationRegistry = pub type CorrelationRegistry =
Arc<tokio::sync::Mutex<HashMap<u32, oneshot::Sender<ForwardedSession>>>>; Arc<tokio::sync::Mutex<HashMap<u32, oneshot::Sender<ForwardedSession>>>>;
/// What a correlated forwarded session delivers: the forwarded /// What a correlated forwarded session delivers: the forwarded
/// channel's `BiStream` (raw pass-through — the SOCKS5 BIND data plane /// channel's `BiStream` (raw pass-through, since the SOCKS5 BIND data
/// is raw on both ends) + the forwarded op's `peer` param (reply#2's /// plane is raw on both ends) + the forwarded
/// real peer — the graduation's win). Public because the /// op's `peer` param (reply#2's real peer, the graduation's win).
/// Public because the
/// [`CorrelationRegistry`] it travels through is public API surface /// [`CorrelationRegistry`] it travels through is public API surface
/// (the assembly holds the handle [`register_forwarded_serving`] /// (the assembly holds the handle [`register_forwarded_serving`]
/// returns); constructing one is the forwarded handler's job alone. /// returns); constructing one is the forwarded handler's job alone.
@@ -470,19 +474,21 @@ pub struct ForwardedSession {
/// The forwarded-op serving handler (the provider's shared opener /// The forwarded-op serving handler (the provider's shared opener
/// half): correlates the arriving forwarded open by `listen` token and /// half): correlates the arriving forwarded open by `listen` token and
/// OWNS the data-plane pump (R-02 — the `register_openable` wrapper /// OWNS the data-plane pump (R-02: the `register_openable` wrapper
/// awaits this task inline, then tears the forwarded channel down; a /// awaits this task inline, then tears the forwarded channel down; a
/// handler that returns early reaps the channel at birth). The pump /// handler that returns early reaps the channel at birth). The pump
/// bridges the forwarded channel's `BiStream` to a local duplex pair: /// bridges the forwarded channel's `BiStream` to a local duplex pair:
/// the near end pumps here (this task lives exactly as long as the /// the near end pumps here (this task lives exactly as long as the
/// data plane — either end's EOF ends it), the far end travels through /// data plane, so either end's EOF ends it), the far end travels
/// through
/// the token's oneshot into the accept future's [`BindTargetHandle`] /// the token's oneshot into the accept future's [`BindTargetHandle`]
/// payloads (dropping the handle drops the far end → the pump sees EOF /// payloads (dropping the handle drops the far end → the pump sees
/// → the task returns → the wrapper reaps the forwarded channel — /// EOF → the task returns → the wrapper reaps the forwarded channel,
/// teardown-at-birth, structural). A correlation miss (no waiting /// i.e. teardown-at-birth, structural). A correlation miss (no
/// session — client gone, or a token nobody minted) drops the stream: /// waiting
/// session (client gone, or a token nobody minted) drops the stream:
/// the task returns immediately, the same reaping shape. A dead /// the task returns immediately, the same reaping shape. A dead
/// oneshot receiver makes the send fail — the task drops the stream /// oneshot receiver makes the send fail, and the task drops the stream
/// and returns (same reaping). /// and returns (same reaping).
fn make_forwarded_handler(registry: CorrelationRegistry) -> alktunnels::ForwardedHandler { fn make_forwarded_handler(registry: CorrelationRegistry) -> alktunnels::ForwardedHandler {
Arc::new(move |forwarded: TunnelForwardedParams, bidi: BiStream| { Arc::new(move |forwarded: TunnelForwardedParams, bidi: BiStream| {
@@ -518,15 +524,15 @@ fn make_forwarded_handler(registry: CorrelationRegistry) -> alktunnels::Forwarde
} }
// The middle pump (three-party half-close would cycle: // The middle pump (three-party half-close would cycle:
// pump_bidi's per-direction shutdown-on-completion // pump_bidi's per-direction shutdown-on-completion
// deadlocks a bridge — the SOCKS5 pump's far.write shutdown // deadlocks a bridge: the SOCKS5 pump's far.write shutdown
// needs near.read EOF; the wire's bidi.read EOF needs the // needs near.read EOF; the wire's bidi.read EOF needs the
// producer pump's far.read EOF; far.read needs near.write // producer pump's far.read EOF; far.read needs near.write
// shutdown; near.write needs bidi.read EOF). The bridge // shutdown; near.write needs bidi.read EOF). The bridge
// therefore completes on EITHER direction's EOF and drops // therefore completes on EITHER direction's EOF and drops
// everything: the far halves (the SOCKS5 pump sees EOF), // everything: the far halves (the SOCKS5 pump sees EOF),
// the near halves, and the BiStream (the forwarded channel // the near halves, and the BiStream (the forwarded channel
// tears down; the wrapper's inline await returns — R-02 — // tears down; the wrapper's inline await returns, per
// and reaps the entry). // R-02, and reaps the entry).
let (near_read, near_write) = tokio::io::split(near); let (near_read, near_write) = tokio::io::split(near);
pump_bidi(bidi, near_read, near_write).await; pump_bidi(bidi, near_read, near_write).await;
}) })
@@ -535,16 +541,16 @@ fn make_forwarded_handler(registry: CorrelationRegistry) -> alktunnels::Forwarde
/// The forwarded channel's `BiStream` reassembled after the /// The forwarded channel's `BiStream` reassembled after the
/// payload-split: the read half rides the [`SyncHalves`] adapter (the /// payload-split: the read half rides the [`SyncHalves`] adapter (the
/// mutex-joined form), the write half rides [`OwnedWrite`] — and the /// mutex-joined form), the write half rides [`OwnedWrite`], and the
/// owner rides inside the write payload so dropping the handle drops /// owner rides inside the write payload so dropping the handle drops
/// everything: the teardown-at-birth discipline, compiler-enforced. /// everything: the teardown-at-birth discipline, compiler-enforced.
/// The owner is the [`TunnelListener`] (NOT an adopted session — the /// The owner is the [`TunnelListener`] (NOT an adopted session, since
/// forwarded channel's entry is the producer's, adopted inside the /// the forwarded channel's entry is the producer's, adopted inside the
/// producer's `ForwardedOpener`): the listener's keep-alive holds the /// producer's `ForwardedOpener`): the listener's keep-alive holds the
/// far accept loop (and every per-accept pump it spawned — R-02) /// far accept loop (and every per-accept pump it spawned; R-02)
/// alive; dropping the handle drops the listener → the listen channel /// alive; dropping the handle drops the listener → the listen channel
/// EOFs → the far teardown cascades. The edition-2021 disjoint-capture /// EOFs → the far teardown cascades. The edition-2021 disjoint-capture
/// trap (an earlier draft took `listener.channel_id` — a `u32` — and /// trap (an earlier draft took `listener.channel_id`, a `u32`, and
/// dropped the listener at the factory's return, EOF-ing the listen /// dropped the listener at the factory's return, EOF-ing the listen
/// channel at birth) is the reason the listener is forced into the /// channel at birth) is the reason the listener is forced into the
/// future with a whole-struct rebinding. /// future with a whole-struct rebinding.
@@ -554,14 +560,14 @@ struct ForwardedOwner {
/// The `BindTargetHandle` read-half adapter over the forwarded /// The `BindTargetHandle` read-half adapter over the forwarded
/// channel's read half: `BiStream` is `Send + Unpin` but NOT `Sync` /// channel's read half: `BiStream` is `Send + Unpin` but NOT `Sync`
/// (its inner is a `dyn AsyncReadWrite` trait object — no `Sync` /// (its inner is a `dyn AsyncReadWrite` trait object, with no `Sync`
/// supertrait), so the raw halves cannot meet the handle payloads' /// supertrait), so the raw halves cannot meet the handle payloads'
/// `+ Sync` bound directly. A `std::sync::Mutex` guard is the /// `+ Sync` bound directly. A `std::sync::Mutex` guard is the
/// no-unsafe Sync proof (`Mutex<T: Send>` is `Sync`); the RFC 1928 /// no-unsafe Sync proof (`Mutex<T: Send>` is `Sync`); the RFC 1928
/// BIND data plane is a raw pass-through pumped by ONE `pump_bidi` /// BIND data plane is a raw pass-through pumped by ONE `pump_bidi`
/// task at a time, so contention never occurs at runtime — the mutex /// task at a time, so contention never occurs at runtime; the mutex
/// is the Sync-proof shape, not a concurrency mechanism. A poisoned /// is the Sync-proof shape, not a concurrency mechanism. A poisoned
/// lock (a panic in one poll) unwraps via `into_inner` — a panic in /// lock (a panic in one poll) unwraps via `into_inner`, so a panic in
/// one operation does not cascade (AGENTS.md convention 2). /// one operation does not cascade (AGENTS.md convention 2).
struct SyncHalves { struct SyncHalves {
inner: std::sync::Mutex<tokio::io::ReadHalf<tokio::io::DuplexStream>>, inner: std::sync::Mutex<tokio::io::ReadHalf<tokio::io::DuplexStream>>,
@@ -582,10 +588,10 @@ impl AsyncRead for SyncHalves {
} }
} }
/// The write-half payload: the owner ([`ForwardedOwner`] — the /// The write-half payload: the owner ([`ForwardedOwner`], the
/// adopted channel session) and the write half ride inside one /// adopted channel session) and the write half ride inside one
/// `std::sync::Mutex` (the same no-unsafe Sync proof as /// `std::sync::Mutex` (the same no-unsafe Sync proof as
/// [`SyncHalves`] — `TunnelSession` and `WriteHalf<BiStream>` are /// [`SyncHalves`]: `TunnelSession` and `WriteHalf<BiStream>` are
/// `Send` but not `Sync`). Contention never occurs at runtime (one /// `Send` but not `Sync`). Contention never occurs at runtime (one
/// pump task); a poisoned lock unwraps via `into_inner`. /// pump task); a poisoned lock unwraps via `into_inner`.
struct OwnedWrite { struct OwnedWrite {
@@ -636,25 +642,25 @@ impl AsyncWrite for OwnedWrite {
} }
} }
/// The forwarded op's serving registration (the opener half — ADR-008 /// The forwarded op's serving registration (the opener half, ADR-008
/// §3, `register_tunnel_forwarded_openable` + this provider's /// §3, `register_tunnel_forwarded_openable` + this provider's
/// correlation handler): register on the SOCKS5 producer's serving /// correlation handler): register on the SOCKS5 producer's serving
/// registry so its connection serves the forwarded op. Call once per /// registry so its connection serves the forwarded op. Call once per
/// channels connection at assembly time — the registry is shared /// channels connection at assembly time, since the registry is shared
/// across all BIND sessions every [`bindfirst_accept`] factory over /// across all BIND sessions every [`bindfirst_accept`] factory over
/// the same client fabricates (one token space per producer /// the same client fabricates (one token space per producer
/// connection). /// connection).
/// ///
/// The scope gate is `tunnel:forwarded` on the PRODUCER's per-call /// The scope gate is `tunnel:forwarded` on the PRODUCER's per-call
/// identity (the ingress grant — ADR-006 Amendment 1; never implied by /// identity (the ingress grant, ADR-006 Amendment 1; never implied by
/// `tunnel:open`). The returned registry handle feeds the factories; /// `tunnel:open`). The returned registry handle feeds the factories;
/// keep it alongside the client they ride. /// keep it alongside the client they ride.
/// ///
/// # Errors /// # Errors
/// ///
/// A duplicate `channels/tunnel/forwarded` op id on the registry (an /// A duplicate `channels/tunnel/forwarded` op id on the registry (an
/// assembly bug, not a runtime condition) — the underlying /// assembly bug, not a runtime condition), and it surfaces the
/// registration's `String` diagnostic, surfaced (convention 2: no /// underlying registration's `String` diagnostic (convention 2: no
/// panic path; the caller decides). /// panic path; the caller decides).
pub fn register_forwarded_serving( pub fn register_forwarded_serving(
core: &ChannelCore, core: &ChannelCore,
@@ -673,56 +679,58 @@ pub fn register_forwarded_serving(
/// The composed accept factory (ADR-007 variant 2, producer.md /// The composed accept factory (ADR-007 variant 2, producer.md
/// §Wiring `composed::bindfirst_accept`): the `BindAcceptFactory` the /// §Wiring `composed::bindfirst_accept`): the `BindAcceptFactory` the
/// producer registration consumes — the BIND accept whose listener /// producer registration consumes, the BIND accept whose listener
/// lives on an alktunnels 0.2.0 bind-first listen resource /// lives on an alktunnels 0.2.0 bind-first listen resource
/// (`register_tunnel_bindfirst_openable` + `BindFirstInjections` — /// (`register_tunnel_bindfirst_openable` + `BindFirstInjections`,
/// per-listener ACL for free, hub-relayable). /// giving per-listener ACL for free, hub-relayable).
/// ///
/// Per session the factory: /// Per session the factory:
/// ///
/// 1. Opens the listen channel (`TunnelListener::open` — the /// 1. Opens the listen channel (`TunnelListener::open`; the
/// establishment bound covers only this open-op round trip, /// establishment bound covers only this open-op round trip,
/// ADR-007); a failed open is a factory failure (reply#1's mapped /// ADR-007); a failed open is a factory failure (reply#1's mapped
/// code — no phantom listener). /// code, so no phantom listener).
/// 2. Advertises reply#1's BND.ADDR as the REAL `bound` (the open-op /// 2. Advertises reply#1's BND.ADDR as the REAL `bound` (the open-op
/// reply's `bound` field, the alkcall 0.8.0 reply projection — the /// reply's `bound` field, the alkcall 0.8.0 reply projection; the
/// graduation's vanilla-fidelity win; no expected-addr stand-in). /// graduation's vanilla-fidelity win; no expected-addr stand-in).
/// A producer that supplied no `bound`/`listen` (not a bind-first /// A producer that supplied no `bound`/`listen` (not a bind-first
/// resource) fails the factory — no stand-in on this variant. /// resource) fails the factory, with no stand-in on this variant.
/// 3. Inserts the minted `listen` token → oneshot into the /// 3. Inserts the minted `listen` token → oneshot into the
/// correlation registry and returns the accept future parked on /// correlation registry and returns the accept future parked on
/// it. /// it.
/// ///
/// The accept future waits for the ONE forwarded session matching the /// The accept future waits for the ONE forwarded session matching the
/// token (the correlation registry is assembly-side bookkeeping — the /// token (the correlation registry is assembly-side bookkeeping; the
/// listener does not own the receipt queue), then builds the /// listener does not own the receipt queue), then builds the
/// [`BindTargetHandle`]: reply#2's peer is the forwarded op's `peer` /// [`BindTargetHandle`]: reply#2's peer is the forwarded op's `peer`
/// param (the graduation's win — no sentinel), and the data-plane /// param (the graduation's win, with no sentinel), and the data-plane
/// halves are the [`SyncHalves`]/[`OwnedWrite`] adapters over the /// halves are the [`SyncHalves`]/[`OwnedWrite`] adapters over the
/// adopted channel's `BiStream` with the [`ForwardedOwner`] (the /// adopted channel's `BiStream` with the [`ForwardedOwner`] (the
/// adopted channel session) riding inside the write payload — dropping /// adopted channel session) riding inside the write payload, so
/// the handle drops the session AND the halves together. /// dropping the handle drops the session AND the halves together.
/// ///
/// **The client-gone teardown** (the seam contract, end-to-end): the /// **The client-gone teardown** (the seam contract, end-to-end): the
/// listener and the oneshot receiver ride the accept future — the /// listener and the oneshot receiver ride the accept future, so the
/// quiet wait's `select!` drop of that future drops the /// quiet wait's `select!` drop of that future drops the
/// [`TunnelListener`] (its held write half EOFs the listen channel — /// [`TunnelListener`] (its held write half EOFs the listen channel,
/// the far accept dispatcher tears down) AND drops the oneshot /// so the far accept dispatcher tears down) AND drops the oneshot
/// receiver (a later forwarded open for that token finds a dead /// receiver (a later forwarded open for that token finds a dead
/// oneshot and drops the channel stream) — no leaked listener, no /// oneshot and drops the channel stream), leaving no leaked
/// leaked channel. /// listener and no leaked channel.
/// ///
/// Only TCP-stream listen resources are supported (the SOCKS5 BIND /// Only TCP-stream listen resources are supported (the SOCKS5 BIND
/// data plane is raw on both ends; a UDP listen substrate would frame /// data plane is raw on both ends; a UDP listen substrate would
/// — a datagram BIND variant is a different task). A non-TCP `bound` /// frame, and a datagram BIND variant is a different task). A
/// non-TCP `bound`
/// fails the factory (`NotAllowed`). /// fails the factory (`NotAllowed`).
/// ///
/// **The 0x02 layering** (review 003 N-2): on the channels path the /// **The 0x02 layering** (review 003 N-2): on the channels path the
/// ADR-006 command gate precedes this factory — an identity without /// ADR-006 command gate precedes this factory, so an identity
/// without
/// `socks5:bind` never reaches it. A factory-side `NotAllowed` (0x02, /// `socks5:bind` never reaches it. A factory-side `NotAllowed` (0x02,
/// the same code the gate uses) therefore means the *tunnel* layer's /// the same code the gate uses) therefore means the *tunnel* layer's
/// per-listener ACL (this variant's real policy source), not the /// per-listener ACL (this variant's real policy source), not the
/// command gate — semantically distinct, wire-identical. /// command gate (the two are semantically distinct, wire-identical).
pub fn bindfirst_accept( pub fn bindfirst_accept(
client: Arc<ChannelClient>, client: Arc<ChannelClient>,
registry: CorrelationRegistry, registry: CorrelationRegistry,
@@ -746,12 +754,12 @@ pub fn bindfirst_accept(
.map_err(|e| BindAcceptFactoryError::General(format!("bound render: {e}")))?, .map_err(|e| BindAcceptFactoryError::General(format!("bound render: {e}")))?,
Some(SubstrateAddr::Path { path }) => { Some(SubstrateAddr::Path { path }) => {
return Err(BindAcceptFactoryError::NotAllowed(format!( return Err(BindAcceptFactoryError::NotAllowed(format!(
"non-tcp bound address `{path}` — this provider serves TCP listeners only" "non-tcp bound address `{path}`: this provider serves TCP listeners only"
))); )));
} }
None => { None => {
return Err(BindAcceptFactoryError::Shortage( return Err(BindAcceptFactoryError::Shortage(
"the producer supplied no `bound` reply field — not a bind-first \ "the producer supplied no `bound` reply field, so this is not a bind-first \
resource; no stand-in on this variant" resource; no stand-in on this variant"
.into(), .into(),
)); ));
@@ -759,8 +767,8 @@ pub fn bindfirst_accept(
}; };
let token = listener.listen().ok_or_else(|| { let token = listener.listen().ok_or_else(|| {
BindAcceptFactoryError::Shortage( BindAcceptFactoryError::Shortage(
"the producer supplied no `listen` correlation token — not a bind-first \ "the producer supplied no `listen` correlation token, so this is not a \
resource; no stand-in on this variant" bind-first resource; no stand-in on this variant"
.into(), .into(),
) )
})?; })?;
@@ -771,8 +779,8 @@ pub fn bindfirst_accept(
let fut = async move { let fut = async move {
// Force the whole-struct capture: edition-2021 disjoint // Force the whole-struct capture: edition-2021 disjoint
// capture would take `listener.channel_id` (a `u32`) // capture would take `listener.channel_id` (a `u32`)
// and drop the listener itself at the factory's return // and drop the listener itself at the factory's return,
// — EOF-ing the listen channel at birth. The listener // EOF-ing the listen channel at birth. The listener
// rides the future end-to-end (the seam contract). // rides the future end-to-end (the seam contract).
let listener = listener; let listener = listener;
let ForwardedSession { read, write, peer } = match rx.await { let ForwardedSession { read, write, peer } = match rx.await {
@@ -784,7 +792,7 @@ pub fn bindfirst_accept(
); );
// The registry entry was removed without // The registry entry was removed without
// delivery (the forwarded serving half tore // delivery (the forwarded serving half tore
// down — the whole provider is going away). // down; the whole provider is going away).
// The listener's drop tears the listen // The listener's drop tears the listen
// channel down (the held write half EOFs). // channel down (the held write half EOFs).
return Err(BindAcceptError::General( return Err(BindAcceptError::General(
@@ -802,7 +810,7 @@ pub fn bindfirst_accept(
inner: std::sync::Mutex::new(OwnedInner { inner: std::sync::Mutex::new(OwnedInner {
// The listener rides INSIDE the payload: // The listener rides INSIDE the payload:
// its keep-alive holds the far accept loop // its keep-alive holds the far accept loop
// (and every per-accept pump it spawned — // (and every per-accept pump it spawned;
// R-02) alive for the data plane's // R-02) alive for the data plane's
// lifetime; dropping the handle drops the // lifetime; dropping the handle drops the
// listener → the listen channel EOFs → // listener → the listen channel EOFs →
@@ -810,7 +818,7 @@ pub fn bindfirst_accept(
// structural, compiler-enforced). The // structural, compiler-enforced). The
// edition-2021 disjoint-capture trap (an // edition-2021 disjoint-capture trap (an
// earlier draft took `listener.channel_id` // earlier draft took `listener.channel_id`
// — a `u32` — and dropped the listener at // (a `u32`) and dropped the listener at
// the factory's return, EOF-ing the listen // the factory's return, EOF-ing the listen
// channel at birth) is why the whole // channel at birth) is why the whole
// struct is rebound into the future above. // struct is rebound into the future above.
@@ -832,7 +840,8 @@ pub fn bindfirst_accept(
/// ///
/// - IP literals resolve directly. /// - IP literals resolve directly.
/// - A non-IP host fails (reply#1's address is a real socket address /// - A non-IP host fails (reply#1's address is a real socket address
/// on this variant — a hostname bound is the producer's resolution /// on this variant, but a hostname bound is the producer's
/// resolution
/// surface, not the advertised address's). /// surface, not the advertised address's).
/// ///
/// The parse error is a `String` diagnostic (the factory maps it to /// The parse error is a `String` diagnostic (the factory maps it to
@@ -844,7 +853,7 @@ fn resolve_bound(host: &str, port: u16) -> Result<SocketAddr, String> {
} }
/// The forwarded op's `peer` param rendered to reply#2's socket /// The forwarded op's `peer` param rendered to reply#2's socket
/// address (the graduation's peer fidelity — the param is the real /// address (the graduation's peer fidelity; the param is the real
/// peer). A `Path` peer (a unix-socket accept) has no RFC-shaped /// peer). A `Path` peer (a unix-socket accept) has no RFC-shaped
/// address; the RFC sentinel `0.0.0.0:0` carries it (the data plane /// address; the RFC sentinel `0.0.0.0:0` carries it (the data plane
/// is unaffected either way). /// is unaffected either way).
@@ -869,9 +878,9 @@ mod egress_unit_tests {
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
/// The reply-oversize guard fires (review 003 D-2): a reply whose /// The reply-oversize guard fires (review 003 D-2): a reply whose
/// inner bytes exceed the joint bound is a `General` egress error /// inner bytes exceed the joint bound is a `General` egress error,
/// — the guard exists although the tunnel codec's u16 datagram /// though the guard exists in case the tunnel codec's u16 datagram
/// bound upstream should keep every real reply under it. /// bound upstream fails to keep every real reply under it.
#[test] #[test]
fn inner_bytes_oversize_guard_fires() { fn inner_bytes_oversize_guard_fires() {
let addr = ("10.0.0.1", 53u16).to_target_addr().unwrap(); let addr = ("10.0.0.1", 53u16).to_target_addr().unwrap();
@@ -889,8 +898,8 @@ mod egress_unit_tests {
} }
/// The relay task's sender-closed exit (review 003 D-2): the /// The relay task's sender-closed exit (review 003 D-2): the
/// shared queue closed with the egress — the stage exited /// shared queue closed with the egress (the stage exited
/// mid-relay — ends the relay's datagram pump silently (the /// mid-relay), so it ends the relay's datagram pump silently (the
/// `tx.send(..).is_err()` early return), distinct from the EOF /// `tx.send(..).is_err()` early return), distinct from the EOF
/// exit that still reports `Ended`. /// exit that still reports `Ended`.
#[tokio::test] #[tokio::test]
@@ -905,7 +914,7 @@ mod egress_unit_tests {
server_side.shutdown().await.unwrap(); server_side.shutdown().await.unwrap();
drop(server_side); drop(server_side);
let handle = tokio::spawn(relay_read(client_side, tx, "k".into())); let handle = tokio::spawn(relay_read(client_side, tx, "k".into()));
// The relay exits on the closed-queue send (silently) — it // The relay exits on the closed-queue send (silently), and it
// joins promptly with no panic. // joins promptly with no panic.
tokio::time::timeout(std::time::Duration::from_secs(5), handle) tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await .await
@@ -914,7 +923,7 @@ mod egress_unit_tests {
} }
/// The relay task's EOF exit still reports `Ended` (the count's /// The relay task's EOF exit still reports `Ended` (the count's
/// decrement path — the shape the integration tests observe /// decrement path, the shape the integration tests observe
/// indirectly, pinned here directly). /// indirectly, pinned here directly).
#[tokio::test] #[tokio::test]
async fn relay_read_reports_ended_on_eof() { async fn relay_read_reports_ended_on_eof() {
@@ -937,7 +946,7 @@ mod egress_unit_tests {
"a queued datagram: {first:?}" "a queued datagram: {first:?}"
); );
// The second datagram (the batched frame) then the EOF // The second datagram (the batched frame) then the EOF
// marker — drain until the `Ended` marker arrives. // marker; drain until the `Ended` marker arrives.
let mut saw_end = false; let mut saw_end = false;
for _ in 0..3 { for _ in 0..3 {
match rx.recv().await.expect("the queue delivers") { match rx.recv().await.expect("the queue delivers") {
+159 -152
View File
@@ -3,58 +3,59 @@
//! //!
//! [`Socks5ChannelSession`] is the CONNECT session: it opens //! [`Socks5ChannelSession`] is the CONNECT session: it opens
//! `channels/socks5/sub` via `ChannelClient::open_channel` (trivial //! `channels/socks5/sub` via `ChannelClient::open_channel` (trivial
//! params — ADR-001), drives the forked client typestate's //! params, ADR-001), drives the forked client typestate's
//! `use_stream` path over the channel's `BiStream` (POC #1's fit — //! `use_stream` path over the channel's `BiStream` (POC #1's fit:
//! `Socks5Stream` impls `AsyncRead + AsyncWrite` itself, so a session //! `Socks5Stream` impls `AsyncRead + AsyncWrite` itself, so a session
//! over a `BiStream` is the intended use, not a hack), and after the //! over a `BiStream` is the intended use, not a hack), and after the
//! reply presents the raw byte pipe (0 B framing — the session //! reply presents the raw byte pipe (0 B framing; the session
//! reads/writes the target's bytes directly). [`BindSession`] is the //! reads/writes the target's bytes directly). [`BindSession`] is the
//! BIND session: the reply#2 reader (consumer.md §The BIND client, //! BIND session and the reply#2 reader (consumer.md §The BIND client,
//! ADR-007) — reply#1 rides the forked client `request()`, reply#2 is //! ADR-007); reply#1 rides the forked client `request()`, reply#2 is
//! hand-read with the forked `read_address` codec (no second codec), //! hand-read with the forked `read_address` codec (no second codec),
//! then the pass-through data plane. [`AssociateSession`] is the UDP //! then the pass-through data plane. [`AssociateSession`] is the UDP
//! ASSOCIATE session (POC #2's shape — see its docs). //! ASSOCIATE session (POC #2's shape; see its docs).
//! //!
//! The BIND session's reply#2 phase is take-guarded (review 002 //! The BIND session's reply#2 phase is take-guarded (review 002
//! C-1): the first `await_inbound` takes the stream — the plane owns //! C-1): the first `await_inbound` takes the stream (the plane owns
//! it, the session stays the teardown owner — and a second call //! it, the session stays the teardown owner), and a second call
//! returns the family's spent shape (`Ok(None)`); the reply#2 reader //! returns the family's spent shape (`Ok(None)`); the reply#2 reader
//! is that one method alone (`reply1()` is a view, D-1). //! is that one method alone (`reply1()` is a view, D-1).
//! //!
//! Target construction rides [`ToTargetAddr`]: IP-string forms parse //! Target construction rides [`ToTargetAddr`]: IP-string forms parse
//! to IP targets; the domain form passes through unresolved (ADR-012 //! to IP targets; the domain form passes through unresolved (ADR-012;
//! — resolution is the egress's job, never the protocol's). //! resolution is the egress's job, never the protocol's).
//! //!
//! [`AssociateSession`] is the UDP ASSOCIATE session (POC #2's shape, //! [`AssociateSession`] is the UDP ASSOCIATE session (POC #2's shape,
//! consumer.md §The associate session, ADR-003): it opens the //! consumer.md §The associate session, ADR-003): it opens the
//! channel, negotiates, sends ASSOCIATE, reads the reply — **receives //! channel, negotiates, sends ASSOCIATE, reads the reply, **receiving
//! the sentinel `BND.ADDR` and never uses it for addressing** (the //! the sentinel `BND.ADDR` and never using it for addressing** (the
//! handle is the channel). The datagram plane is the framed stage: //! handle is the channel). The datagram plane is the framed stage:
//! `send_datagram` frames once (`[len: u16 BE][addr + data]` — the //! `send_datagram` frames once (`[len: u16 BE][addr + data]`; the
//! framing-once mirror, ADR-003) and `recv_datagram` decodes //! framing-once mirror, ADR-003) and `recv_datagram` decodes
//! incrementally with **reader state that persists across calls** //! incrementally with **reader state that persists across calls**
//! (mid-frame position + batched-datagram pending queue — a datagram //! (mid-frame position + batched-datagram pending queue, so a
//! split across channel chunks survives a caller-driven receive //! datagram split across channel chunks survives a caller-driven
//! loop; POC #2's finding: the first cut dropped the reader state //! receive loop; POC #2's finding: the first cut dropped the reader
//! per call, the 2-byte-prefix chunk test exposed it). `shutdown` //! state per call, the 2-byte-prefix chunk test exposed it). `shutdown`
//! half-closes the stream — the mux's implicit-EOF sentinel reaches //! half-closes the stream; the mux's implicit-EOF sentinel reaches
//! the producer's stage as EOF and the association ends (association //! the producer's stage as EOF and the association ends (association
//! lifetime = channel lifetime). //! lifetime = channel lifetime).
//! //!
//! Teardown ownership (alktunnels ADR-005 posture, inherited via //! Teardown ownership (alktunnels ADR-005 posture, inherited via
//! ADR-004): the session owns the adopted channel entry. On Drop it //! ADR-004): the session owns the adopted channel entry. On Drop it
//! reaps it (`ChannelManager::teardown_channel` — sync, best-effort; //! reaps it (`ChannelManager::teardown_channel`, sync and
//! `Drop` cannot await) and the data plane EOFs structurally (the //! best-effort; `Drop` cannot await) and the data plane EOFs
//! structurally (the
//! mux's implicit-EOF sentinel on the dropped write half). The //! mux's implicit-EOF sentinel on the dropped write half). The
//! teardown-at-birth discipline covers the in-stream refusal path: //! teardown-at-birth discipline covers the in-stream refusal path:
//! a refused ASSOCIATE tears the adopted channel down before the //! a refused ASSOCIATE tears the adopted channel down before the
//! error returns — a refused session never leaves a phantom channel. //! error returns; a refused session never leaves a phantom channel.
//! The session does **not** implement `Clone` (compile-asserted on //! The session does **not** implement `Clone` (compile-asserted on
//! the struct): aliasing a session would alias its teardown and its //! the struct): aliasing a session would alias its teardown and its
//! reader state. //! reader state.
//! //!
//! Error surface (consumer.md §Error surface): open failures are the //! Error surface (consumer.md §Error surface): open failures are the
//! typed [`SocksOpenError`] reasons (ADR-049 §4 — a refused session //! typed [`SocksOpenError`] reasons (ADR-049 §4; a refused session
//! never yields a phantom channel); a refused ASSOCIATE is an RFC //! never yields a phantom channel); a refused ASSOCIATE is an RFC
//! reply code surfaced through [`SocksSessionError::Rfc`]. //! reply code surfaced through [`SocksSessionError::Rfc`].
@@ -76,11 +77,11 @@ use crate::rfc1928::{ReplyError, Socks5Command, SocksError, UdpHeaderError};
use crate::wire::{frame, DatagramReader, WireError}; use crate::wire::{frame, DatagramReader, WireError};
/// The three session constructors' shared prologue (review 002 N-6): /// The three session constructors' shared prologue (review 002 N-6):
/// open the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)` /// open the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`,
/// — the trivial params, ADR-001), join the halves into the channel's /// the trivial params, ADR-001), join the halves into the channel's
/// `BiStream`, run the command negotiation over the forked client /// `BiStream`, run the command negotiation over the forked client
/// typestate, and on any negotiation failure reap the adopted channel /// typestate, and on any negotiation failure reap the adopted channel
/// before the error returns (teardown-at-birth — the phantom-channel /// before the error returns (teardown-at-birth; the phantom-channel
/// property, in one place instead of three). `Ok` yields the channel /// property, in one place instead of three). `Ok` yields the channel
/// id, the negotiated stream, and the cloned manager for the session /// id, the negotiated stream, and the cloned manager for the session
/// to own. /// to own.
@@ -112,17 +113,17 @@ where
/// The cred-bearing front door's re-origination prologue (the /// The cred-bearing front door's re-origination prologue (the
/// token-carrying twin of [`open_and_negotiate`]): open the channel /// token-carrying twin of [`open_and_negotiate`]): open the channel
/// with the payload `auth_token` riding the `call.requested` payload /// with the payload `auth_token` riding the `call.requested` payload
/// (CF-005 precedence (1) — the producer-side dispatch resolves the /// (CF-005 precedence (1): the producer-side dispatch resolves the
/// token through its `ServingConfig.identity_provider` and runs the /// token through its `ServingConfig.identity_provider` and runs the
/// ACL gate against the mapped identity), join the halves, run the /// ACL gate against the mapped identity), join the halves, run the
/// command negotiation, and reap on failure — the same phantom-channel /// command negotiation, and reap on failure, the same phantom-channel
/// property. `Ok` yields the channel id, the negotiated stream, and /// property. `Ok` yields the channel id, the negotiated stream, and
/// the cloned manager. /// the cloned manager.
/// ///
/// The token path needs the raw `call.requested` payload (`auth_token` /// The token path needs the raw `call.requested` payload (`auth_token`
/// is a payload-level field, ADR-017 §7), so the open rides /// is a payload-level field, ADR-017 §7), so the open rides
/// `CallConnection::call_with_payload` + `adopt_channel` — the same /// `CallConnection::call_with_payload` + `adopt_channel` (the same
/// two-step `open_channel` performs, with the token added. The /// two-step `open_channel` performs, with the token added). The
/// client's call connection is taken once and cached in the returned /// client's call connection is taken once and cached in the returned
/// client (the door's `TokenClient` owns it for the door's lifetime). /// client (the door's `TokenClient` owns it for the door's lifetime).
async fn open_with_token_and_negotiate<T, F, Fut>( async fn open_with_token_and_negotiate<T, F, Fut>(
@@ -157,7 +158,7 @@ where
/// The spent-session marker (the gate review's R-2, /// The spent-session marker (the gate review's R-2,
/// tasks/implementation/review-protocol-core.md): [`Socks5ChannelSession /// tasks/implementation/review-protocol-core.md): [`Socks5ChannelSession
/// ::into_inner`] / [`AssociateSession::into_inner`] return it when /// ::into_inner`] / [`AssociateSession::into_inner`] return it when
/// the pipe/stream was already taken — reachable only by calling /// the pipe/stream was already taken, reachable only by calling
/// twice on a session whose inner is gone. Carries nothing: a spent /// twice on a session whose inner is gone. Carries nothing: a spent
/// session's teardown is already done (the reap ran with the take). /// session's teardown is already done (the reap ran with the take).
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -183,9 +184,9 @@ pub struct Socks5ChannelSession {
channel_id: u32, channel_id: u32,
manager: Option<ChannelManager>, manager: Option<ChannelManager>,
inner: Option<Socks5Stream<BiStream>>, inner: Option<Socks5Stream<BiStream>>,
/// The CONNECT reply's BND.ADDR — captured at `connect` (the /// The CONNECT reply's BND.ADDR, captured at `connect` (the
/// forked client `request()` returns it once; the front door's /// forked client `request()` returns it once; the front door's
/// re-origination re-encodes the vanilla reply from it — the RFC /// re-origination re-encodes the vanilla reply from it, the RFC
/// codec at the door boundary only, ADR-003). /// codec at the door boundary only, ADR-003).
reply_addr: Option<TargetAddr>, reply_addr: Option<TargetAddr>,
} }
@@ -201,15 +202,15 @@ impl std::fmt::Debug for Socks5ChannelSession {
impl Socks5ChannelSession { impl Socks5ChannelSession {
/// CONNECT to `target` through a produced SOCKS5 resource: opens /// CONNECT to `target` through a produced SOCKS5 resource: opens
/// the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)` /// the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`,
/// — the trivial params, ADR-001), drives the no-auth handshake /// the trivial params, ADR-001), drives the no-auth handshake
/// and the CONNECT request in-stream over the channel's /// and the CONNECT request in-stream over the channel's
/// `BiStream`, and returns the session owning both. /// `BiStream`, and returns the session owning both.
/// ///
/// Error surface: an open failure is [`SocksSessionError::Open`] /// Error surface: an open failure is [`SocksSessionError::Open`]
/// (the typed ADR-049 reasons; no channel was ever returned); a /// (the typed ADR-049 reasons; no channel was ever returned); a
/// refused target surfaces the RFC reply code through /// refused target surfaces the RFC reply code through
/// [`SocksSessionError::Rfc`] — and the adopted channel is torn /// [`SocksSessionError::Rfc`], and the adopted channel is torn
/// down before the error returns (no phantom channel). /// down before the error returns (no phantom channel).
pub async fn connect( pub async fn connect(
client: &ChannelClient, client: &ChannelClient,
@@ -233,8 +234,8 @@ impl Socks5ChannelSession {
/// The cred-bearing front door's CONNECT re-origination (the /// The cred-bearing front door's CONNECT re-origination (the
/// token-carrying twin of [`Self::connect`]): the open rides the /// token-carrying twin of [`Self::connect`]): the open rides the
/// payload `auth_token` (CF-005 precedence (1) — the producer's /// payload `auth_token` (CF-005 precedence (1): the producer's
/// identity provider resolves the door's mapped identity and its /// identity provider resolves the door's mapped identity, and its
/// scopes gate the open), then the same in-stream negotiation over /// scopes gate the open), then the same in-stream negotiation over
/// the channel's `BiStream`. Error surface identical. /// the channel's `BiStream`. Error surface identical.
pub async fn connect_with_token( pub async fn connect_with_token(
@@ -265,7 +266,7 @@ impl Socks5ChannelSession {
self.channel_id self.channel_id
} }
/// The CONNECT reply's BND.ADDR — the in-band reply's bound /// The CONNECT reply's BND.ADDR: the in-band reply's bound
/// address, captured at `connect` (the forked client `request()` /// address, captured at `connect` (the forked client `request()`
/// returns it once). The front door's re-origination re-encodes /// returns it once). The front door's re-origination re-encodes
/// the vanilla reply from it (the RFC codec at the door boundary /// the vanilla reply from it (the RFC codec at the door boundary
@@ -275,8 +276,8 @@ impl Socks5ChannelSession {
self.reply_addr.clone() self.reply_addr.clone()
} }
/// The post-CONNECT pipe as split read/write halves (0 B framing /// The post-CONNECT pipe as split read/write halves (0 B framing,
/// — raw bytes to/from the target). The halves borrow the /// raw bytes to/from the target). The halves borrow the
/// session: teardown stays owned (`close`/`Drop`). `None` after /// session: teardown stays owned (`close`/`Drop`). `None` after
/// [`Self::into_inner`] took the pipe. /// [`Self::into_inner`] took the pipe.
pub fn split(&mut self) -> Option<SplitPipe<'_>> { pub fn split(&mut self) -> Option<SplitPipe<'_>> {
@@ -284,16 +285,16 @@ impl Socks5ChannelSession {
Some(tokio::io::split(inner)) Some(tokio::io::split(inner))
} }
/// Take the underlying [`Socks5Stream<BiStream>`] (advanced use — /// Take the underlying [`Socks5Stream<BiStream>`] (advanced use;
/// the forked client type's socket accessors, owned-half pumps). /// the forked client type's socket accessors, owned-half pumps).
/// The session rides along still owning the channel's teardown: /// The session rides along still owning the channel's teardown:
/// drop the pair (or `close` the session) when done — dropping /// drop the pair (or `close` the session) when done, since
/// `inner` EOFs the data plane structurally. /// dropping `inner` EOFs the data plane structurally.
/// ///
/// [`SpentSessionError`] on a spent session (the pipe already /// [`SpentSessionError`] on a spent session (the pipe already
/// taken): a spent session's manager entry is always reaped /// taken): a spent session's manager entry is always reaped
/// already (`close`/`shutdown`/`Drop` all take it), so dropping /// already (`close`/`shutdown`/`Drop` all take it), so dropping
/// it loses nothing — no panic path (AGENTS.md convention 2). /// it loses nothing (no panic path, AGENTS.md convention 2).
pub fn into_inner(mut self) -> Result<TakenInner, SpentSessionError> { pub fn into_inner(mut self) -> Result<TakenInner, SpentSessionError> {
match self.inner.take() { match self.inner.take() {
Some(inner) => Ok(TakenInner { Some(inner) => Ok(TakenInner {
@@ -317,9 +318,9 @@ impl Socks5ChannelSession {
/// The adopted channel's teardown (`teardown_channel` returns a /// The adopted channel's teardown (`teardown_channel` returns a
/// producer-side pump-task handle the caller may abort after the /// producer-side pump-task handle the caller may abort after the
/// drain — the channels-call path's shape). The handle is /// drain, the channels-call path's shape). The handle is
/// deliberately dropped here: the producer-side pump finishes via /// deliberately dropped here: the producer-side pump finishes via
/// EOF propagation (the graceful shape — aborting would cut /// EOF propagation (the graceful shape, since aborting would cut
/// mid-flight data); `clear_all` aborts instead (the substrate's /// mid-flight data); `clear_all` aborts instead (the substrate's
/// abrupt path). /// abrupt path).
fn reap(&mut self) -> bool { fn reap(&mut self) -> bool {
@@ -332,7 +333,7 @@ impl Socks5ChannelSession {
impl Drop for Socks5ChannelSession { impl Drop for Socks5ChannelSession {
/// Sync-reap the adopted channel entry (`teardown_channel` is /// Sync-reap the adopted channel entry (`teardown_channel` is
/// sync — best-effort; `Drop` cannot await) and drop the pipe /// sync, best-effort; `Drop` cannot await) and drop the pipe
/// (the mux's implicit-EOF sentinel EOFs the producer's pump). /// (the mux's implicit-EOF sentinel EOFs the producer's pump).
/// Dropping without `close` never leaks the channel entry. /// Dropping without `close` never leaks the channel entry.
fn drop(&mut self) { fn drop(&mut self) {
@@ -342,8 +343,8 @@ impl Drop for Socks5ChannelSession {
} }
/// The UDP ASSOCIATE consumer session (POC #2's shape, consumer.md /// The UDP ASSOCIATE consumer session (POC #2's shape, consumer.md
/// §The associate session, ADR-003): the channel is the relay handle /// §The associate session, ADR-003): the channel is the relay handle;
/// — the reply's sentinel `BND.ADDR` is received and never used for /// the reply's sentinel `BND.ADDR` is received and never used for
/// addressing, the datagram plane is the framed stage on the channel /// addressing, the datagram plane is the framed stage on the channel
/// (`[len: u16 BE][addr + data]`, both directions), and the /// (`[len: u16 BE][addr + data]`, both directions), and the
/// association's lifetime is the channel's. /// association's lifetime is the channel's.
@@ -352,7 +353,7 @@ impl Drop for Socks5ChannelSession {
/// = one ASSOCIATE, convention 10) and owns its reader state: the /// = one ASSOCIATE, convention 10) and owns its reader state: the
/// incremental [`DatagramReader`] (mid-frame position) and the /// incremental [`DatagramReader`] (mid-frame position) and the
/// pending-queue of batched datagrams both **persist across /// pending-queue of batched datagrams both **persist across
/// `recv_datagram` calls** — a datagram split across channel chunks /// `recv_datagram` calls**; a datagram split across channel chunks
/// survives a caller-driven receive loop (POC #2's finding, the /// survives a caller-driven receive loop (POC #2's finding, the
/// first cut dropped the reader state per call). It does **not** /// first cut dropped the reader state per call). It does **not**
/// implement `Clone`: /// implement `Clone`:
@@ -370,17 +371,17 @@ pub struct AssociateSession {
channel_id: u32, channel_id: u32,
manager: Option<ChannelManager>, manager: Option<ChannelManager>,
inner: Option<Socks5Stream<BiStream>>, inner: Option<Socks5Stream<BiStream>>,
/// The incremental length-codec decoder — its mid-frame state /// The incremental length-codec decoder; its mid-frame state
/// persists across `recv_datagram` calls (a datagram may be split /// persists across `recv_datagram` calls (a datagram may be split
/// across channel chunks; the same `DatagramReader` the producer /// across channel chunks; the same `DatagramReader` the producer
/// stage runs). /// stage runs).
reader: DatagramReader, reader: DatagramReader,
/// Datagrams decoded beyond the one returned (a chunk may batch /// Datagrams decoded beyond the one returned (a chunk may batch
/// several datagrams — drained by later `recv_datagram` calls). /// several datagrams, drained by later `recv_datagram` calls).
/// The batched datagrams are stored decoded (`(addr, payload)` — /// The batched datagrams are stored decoded (`(addr, payload)`,
/// the same shape `recv_datagram` returns). /// the same shape `recv_datagram` returns).
pending: std::collections::VecDeque<(TargetAddr, Bytes)>, pending: std::collections::VecDeque<(TargetAddr, Bytes)>,
/// The reply's BND.ADDR — received for RFC fidelity, unused for /// The reply's BND.ADDR, received for RFC fidelity, unused for
/// addressing (wrapper-aware clients sendto the channel; ADR-003). /// addressing (wrapper-aware clients sendto the channel; ADR-003).
relay_addr: Option<TargetAddr>, relay_addr: Option<TargetAddr>,
} }
@@ -398,13 +399,13 @@ impl std::fmt::Debug for AssociateSession {
impl AssociateSession { impl AssociateSession {
/// ASSOCIATE through a produced SOCKS5 resource: opens the /// ASSOCIATE through a produced SOCKS5 resource: opens the
/// channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)` — the /// channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`, the
/// trivial params, ADR-001), drives the no-auth handshake and the /// trivial params, ADR-001), drives the no-auth handshake and the
/// UDP ASSOCIATE request in-stream over the channel's `BiStream`, /// UDP ASSOCIATE request in-stream over the channel's `BiStream`,
/// and reads the reply. The DST.ADDR of the ASSOCIATE command /// and reads the reply. The DST.ADDR of the ASSOCIATE command
/// mirrors the vanilla shape — the unspecified address (`[::]:0`), /// mirrors the vanilla shape: the unspecified address (`[::]:0`),
/// as the forked `Socks5Datagram` sends. The reply's BND.ADDR is /// as the forked `Socks5Datagram` sends. The reply's BND.ADDR is
/// the producer's sentinel (`127.0.0.1:0` on the in-band path) — /// the producer's sentinel (`127.0.0.1:0` on the in-band path),
/// captured as [`Self::relay_addr`], never used for addressing /// captured as [`Self::relay_addr`], never used for addressing
/// (ADR-003: the handle is the channel). /// (ADR-003: the handle is the channel).
/// ///
@@ -412,7 +413,7 @@ impl AssociateSession {
/// (the typed ADR-049 reasons; no channel was ever returned); a /// (the typed ADR-049 reasons; no channel was ever returned); a
/// refused ASSOCIATE (the missing-egress `CommandNotSupported` /// refused ASSOCIATE (the missing-egress `CommandNotSupported`
/// 0x07 or a factory failure's mapped code) surfaces the RFC /// 0x07 or a factory failure's mapped code) surfaces the RFC
/// reply code through [`SocksSessionError::Rfc`] — and the /// reply code through [`SocksSessionError::Rfc`], and the
/// adopted channel is torn down before the error returns (no /// adopted channel is torn down before the error returns (no
/// phantom channel). /// phantom channel).
pub async fn associate(client: &ChannelClient) -> Result<Self, SocksSessionError> { pub async fn associate(client: &ChannelClient) -> Result<Self, SocksSessionError> {
@@ -441,7 +442,7 @@ impl AssociateSession {
/// The cred-bearing front door's ASSOCIATE re-origination (the /// The cred-bearing front door's ASSOCIATE re-origination (the
/// token-carrying twin of [`Self::associate`]): the open rides /// token-carrying twin of [`Self::associate`]): the open rides
/// the payload `auth_token` (CF-005 precedence (1)). Not called /// the payload `auth_token` (CF-005 precedence (1)). Not called
/// by the landed TCP door — its ASSOCIATE arm refuses /// by the landed TCP door, whose ASSOCIATE arm refuses
/// RFC-faithfully before any session is opened (the datagram /// RFC-faithfully before any session is opened (the datagram
/// plane is the UDP door's task, ADR-003); the constructor is /// plane is the UDP door's task, ADR-003); the constructor is
/// the session family's completeness (every command carries the /// the session family's completeness (every command carries the
@@ -482,7 +483,7 @@ impl AssociateSession {
/// The reply's sentinel `BND.ADDR` (RFC fidelity: the relay /// The reply's sentinel `BND.ADDR` (RFC fidelity: the relay
/// address the vanilla shape would sendto; the wrapper-aware /// address the vanilla shape would sendto; the wrapper-aware
/// session never uses it — the handle is the channel, ADR-003). /// session never uses it (the handle is the channel, ADR-003).
/// `None` after a taken stream (the spent-session posture). /// `None` after a taken stream (the spent-session posture).
pub fn relay_addr(&self) -> Option<TargetAddr> { pub fn relay_addr(&self) -> Option<TargetAddr> {
self.relay_addr.clone() self.relay_addr.clone()
@@ -490,10 +491,10 @@ impl AssociateSession {
/// Send one datagram to `dst`: the RFC address encoding + the /// Send one datagram to `dst`: the RFC address encoding + the
/// payload, length-framed **exactly once** by the wire codec /// payload, length-framed **exactly once** by the wire codec
/// ([`frame`] — the framing-once mirror, ADR-003) and written to /// ([`frame`], the framing-once mirror, ADR-003) and written to
/// the channel. A zero-length payload is a legal empty datagram /// the channel. A zero-length payload is a legal empty datagram
/// (the F-2 invariant at payload level). Rejects /// (the F-2 invariant at payload level). Rejects
/// [`SocksSessionError::Rfc`] ([`WireError`]) at frame time — /// [`SocksSessionError::Rfc`] ([`WireError`]) at frame time,
/// the joint bound (`len(addr) + len(payload) > 65535`, the u16 /// the joint bound (`len(addr) + len(payload) > 65535`, the u16
/// length field would wrap) and the address-encoding refusals. /// length field would wrap) and the address-encoding refusals.
pub async fn send_datagram( pub async fn send_datagram(
@@ -516,28 +517,28 @@ impl AssociateSession {
} }
/// Receive the next datagram: decode incrementally from the /// Receive the next datagram: decode incrementally from the
/// channel's read half — `(source_addr, payload)` per datagram, /// channel's read half (`(source_addr, payload)` per datagram),
/// `Ok(None)` on channel EOF (the association's end — the /// `Ok(None)` on channel EOF (the association's end, per the
/// association lifetime is the channel's, ADR-003). /// association-lifetime contract, ADR-003).
/// ///
/// **The reader state persists across calls** (POC #2's finding, /// **The reader state persists across calls** (POC #2's finding,
/// ADR-003): the incremental [`DatagramReader`]'s mid-frame /// ADR-003): the incremental [`DatagramReader`]'s mid-frame
/// position survives a caller-driven receive loop — a datagram /// position survives a caller-driven receive loop (a datagram
/// split across channel chunks reassembles across calls — and /// split across channel chunks reassembles across calls), and
/// frames decoded beyond the one returned (a chunk may batch /// frames decoded beyond the one returned (a chunk may batch
/// several datagrams) queue in `pending` for later calls. /// several datagrams) queue in `pending` for later calls.
/// ///
/// Fail-loud shapes (never a silent partial datagram): a stream /// Fail-loud shapes (never a silent partial datagram): a stream
/// end mid-frame is the truncated-datagram condition (an /// end mid-frame is the truncated-datagram condition (an
/// `UnexpectedEof` I/O error through [`SocksSessionError::Rfc`]); /// `UnexpectedEof` I/O error through [`SocksSessionError::Rfc`]);
/// a frame the codec refuses (garbage ATYP — any post-reply /// a frame the codec refuses (garbage ATYP: any post-reply
/// control byte) errors loud through [`WireError`]'s /// control byte) errors loud through [`WireError`]'s
/// `UdpHeaderError` mapping and ends the caller's loop. /// `UdpHeaderError` mapping and ends the caller's loop.
pub async fn recv_datagram( pub async fn recv_datagram(
&mut self, &mut self,
) -> Result<Option<(TargetAddr, Bytes)>, SocksSessionError> { ) -> Result<Option<(TargetAddr, Bytes)>, SocksSessionError> {
// The batched-datagram pending queue first (a chunk may have // The batched-datagram pending queue first (a chunk may have
// batched several frames — drain in order). // batched several frames, drained in order).
if let Some(dg) = self.pending.pop_front() { if let Some(dg) = self.pending.pop_front() {
return Ok(Some(dg)); return Ok(Some(dg));
} }
@@ -550,7 +551,7 @@ impl AssociateSession {
loop { loop {
let n = socket.read(&mut chunk).await?; let n = socket.read(&mut chunk).await?;
if n == 0 { if n == 0 {
// Channel EOF = association end (the clean exit) — // Channel EOF = association end (the clean exit);
// fail-loud if a frame was in flight (never a silent // fail-loud if a frame was in flight (never a silent
// partial datagram; the reader state is the teardown // partial datagram; the reader state is the teardown
// diagnostic). // diagnostic).
@@ -580,7 +581,7 @@ impl AssociateSession {
/// The underlying stream as split read/write halves (the front /// The underlying stream as split read/write halves (the front
/// door's bridge seam: a raw pipe to a vanilla client over the /// door's bridge seam: a raw pipe to a vanilla client over the
/// framed stage — the door's RFC header codec owns the frames at /// framed stage; the door's RFC header codec owns the frames at
/// the boundary, ADR-003). `None` after the stream was taken (the /// the boundary, ADR-003). `None` after the stream was taken (the
/// spent-session posture). /// spent-session posture).
pub fn split(&mut self) -> Option<SplitPipe<'_>> { pub fn split(&mut self) -> Option<SplitPipe<'_>> {
@@ -588,7 +589,7 @@ impl AssociateSession {
Some(tokio::io::split(inner)) Some(tokio::io::split(inner))
} }
/// Take the underlying [`Socks5Stream<BiStream>`] (advanced use — /// Take the underlying [`Socks5Stream<BiStream>`] (advanced use;
/// the forked client type's socket accessors, owned-half pumps; /// the forked client type's socket accessors, owned-half pumps;
/// the POC's `raw_mut` seam, widened). The session rides along /// the POC's `raw_mut` seam, widened). The session rides along
/// still owning the channel's teardown: drop the pair (or /// still owning the channel's teardown: drop the pair (or
@@ -597,7 +598,7 @@ impl AssociateSession {
/// [`SpentSessionError`] on a spent session (the stream already /// [`SpentSessionError`] on a spent session (the stream already
/// taken): a spent session's manager entry is always reaped /// taken): a spent session's manager entry is always reaped
/// already (`shutdown`/`close`/`Drop` all take it), so dropping it /// already (`shutdown`/`close`/`Drop` all take it), so dropping it
/// loses nothing — no panic path (AGENTS.md convention 2), and the /// loses nothing (no panic path, AGENTS.md convention 2), and the
/// `Err` side stays small (a spent session is not carried). /// `Err` side stays small (a spent session is not carried).
pub fn into_inner(mut self) -> Result<TakenAssociateInner, SpentSessionError> { pub fn into_inner(mut self) -> Result<TakenAssociateInner, SpentSessionError> {
match self.inner.take() { match self.inner.take() {
@@ -640,9 +641,9 @@ impl AssociateSession {
/// The adopted channel's teardown (`teardown_channel` returns a /// The adopted channel's teardown (`teardown_channel` returns a
/// producer-side pump-task handle the caller may abort after the /// producer-side pump-task handle the caller may abort after the
/// drain — the channels-call path's shape). The handle is /// drain, the channels-call path's shape). The handle is
/// deliberately dropped here: the producer-side pump finishes via /// deliberately dropped here: the producer-side pump finishes via
/// EOF propagation (the graceful shape — aborting would cut /// EOF propagation (the graceful shape, since aborting would cut
/// mid-flight data); `clear_all` aborts instead (the substrate's /// mid-flight data); `clear_all` aborts instead (the substrate's
/// abrupt path). /// abrupt path).
fn reap(&mut self) -> bool { fn reap(&mut self) -> bool {
@@ -655,8 +656,8 @@ impl AssociateSession {
impl Drop for AssociateSession { impl Drop for AssociateSession {
/// Sync-reap the adopted channel entry (`teardown_channel` is /// Sync-reap the adopted channel entry (`teardown_channel` is
/// sync — best-effort; `Drop` cannot await) and drop the pipe /// sync, best-effort; `Drop` cannot await) and drop the pipe
/// (the mux's implicit-EOF sentinel EOFs the producer's stage — /// (the mux's implicit-EOF sentinel EOFs the producer's stage,
/// the association ends with the session's lifetime). Dropping /// the association ends with the session's lifetime). Dropping
/// without `shutdown`/`close` never leaks the channel entry. /// without `shutdown`/`close` never leaks the channel entry.
fn drop(&mut self) { fn drop(&mut self) {
@@ -668,7 +669,7 @@ impl Drop for AssociateSession {
/// The owned stream [`AssociateSession::into_inner`] yields, paired /// The owned stream [`AssociateSession::into_inner`] yields, paired
/// with the session that still owns the channel's teardown. /// with the session that still owns the channel's teardown.
pub struct TakenAssociateInner { pub struct TakenAssociateInner {
/// The teardown owner — reap via `shutdown`/`close`/`Drop`. /// The teardown owner: reap via `shutdown`/`close`/`Drop`.
pub session: AssociateSession, pub session: AssociateSession,
/// The framed datagram stage's stream (the forked client type /// The framed datagram stage's stream (the forked client type
/// over the channel's `BiStream`). /// over the channel's `BiStream`).
@@ -678,7 +679,7 @@ pub struct TakenAssociateInner {
/// The owned pipe [`Socks5ChannelSession::into_inner`] yields, paired /// The owned pipe [`Socks5ChannelSession::into_inner`] yields, paired
/// with the session that still owns the channel's teardown. /// with the session that still owns the channel's teardown.
pub struct TakenInner { pub struct TakenInner {
/// The teardown owner — reap via `close`/`Drop`. /// The teardown owner: reap via `close`/`Drop`.
pub session: Socks5ChannelSession, pub session: Socks5ChannelSession,
/// The post-CONNECT pipe (the forked client type over the /// The post-CONNECT pipe (the forked client type over the
/// channel's `BiStream`). /// channel's `BiStream`).
@@ -694,7 +695,7 @@ pub type SplitPipe<'s> = (
); );
/// A completed BIND reply#1 (consumer.md §The BIND client, ADR-007's /// A completed BIND reply#1 (consumer.md §The BIND client, ADR-007's
/// client-side phase reading): the channel is open, reply#1 carried /// client-side phase reading): the channel is open, reply#1 carried
/// BND.ADDR — the advertised address the app server dials (the one /// BND.ADDR, the advertised address the app server dials (the one
/// real address on the channels path: the local variant's bound /// real address on the channels path: the local variant's bound
/// listener; the composed variant's expected far-side address; the /// listener; the composed variant's expected far-side address; the
/// wrapper-aware sentinel `0.0.0.0:0` on the in-band default). The /// wrapper-aware sentinel `0.0.0.0:0` on the in-band default). The
@@ -704,15 +705,15 @@ pub type SplitPipe<'s> = (
/// one-reader decision): it carries the advertised address and the /// one-reader decision): it carries the advertised address and the
/// raw pre-reply#2 stream ([`Self::raw_mut`], the front-door bridge /// raw pre-reply#2 stream ([`Self::raw_mut`], the front-door bridge
/// seam); the one normative reply#2 reader is /// seam); the one normative reply#2 reader is
/// [`BindSession::await_inbound`] alone, take-guarded there — a /// [`BindSession::await_inbound`] alone, take-guarded there, so a
/// fresh handle per call cannot double-read anything. /// fresh handle per call cannot double-read anything.
/// ///
/// The session owns the adopted channel — teardown stays there /// The session owns the adopted channel, so teardown stays there
/// (reap via the session; this handle borrows into it). No `Clone` /// (reap via the session; this handle borrows into it). No `Clone`
/// (the compile-assert rides [`BindSession`]). /// (the compile-assert rides [`BindSession`]).
pub struct BindReply1<'s> { pub struct BindReply1<'s> {
inner: &'s mut Socks5Stream<BiStream>, inner: &'s mut Socks5Stream<BiStream>,
/// Reply#1's BND.ADDR — the advertised address. /// Reply#1's BND.ADDR, the advertised address.
pub bind_addr: TargetAddr, pub bind_addr: TargetAddr,
} }
@@ -725,11 +726,11 @@ impl BindReply1<'_> {
} }
/// The post-reply#2 data plane (the pass-through to the accepted /// The post-reply#2 data plane (the pass-through to the accepted
/// inbound peer): 0 B framing — reads/writes ride the channel /// inbound peer): 0 B framing; reads/writes ride the channel
/// directly. The plane **owns** the stream (review 002 C-1's /// directly. The plane **owns** the stream (review 002 C-1's
/// take-shape — the taken inner pairs with the session that stays /// take-shape: the taken inner pairs with the session that stays
/// the teardown owner, the `TakenInner` pairing posture); the /// the teardown owner, the `TakenInner` pairing posture); the
/// [`BindSession`] keeps the channel's teardown — drop it (or /// [`BindSession`] keeps the channel's teardown; drop it (or
/// `close` it) when done; dropping the plane EOFs the pipe /// `close` it) when done; dropping the plane EOFs the pipe
/// structurally (the mux's implicit-EOF sentinel). Accessors: /// structurally (the mux's implicit-EOF sentinel). Accessors:
/// [`Self::into_inner`] (owned) / [`Self::get_socket_mut`] /// [`Self::into_inner`] (owned) / [`Self::get_socket_mut`]
@@ -737,7 +738,7 @@ impl BindReply1<'_> {
pub struct BindDataPlane { pub struct BindDataPlane {
inner: Socks5Stream<BiStream>, inner: Socks5Stream<BiStream>,
/// Reply#2's peer address (the RFC's "the address of the incoming /// Reply#2's peer address (the RFC's "the address of the incoming
/// connection" — the attribution surface, in-band). /// connection", the attribution surface, in-band).
pub peer: TargetAddr, pub peer: TargetAddr,
} }
@@ -751,7 +752,7 @@ impl BindDataPlane {
} }
/// The underlying stream (the forked client type's socket /// The underlying stream (the forked client type's socket
/// accessor, borrowed — the POC's `get_socket_mut` shape). /// accessor, borrowed, i.e. the POC's `get_socket_mut` shape).
pub fn get_socket_mut(&mut self) -> &mut BiStream { pub fn get_socket_mut(&mut self) -> &mut BiStream {
self.inner.get_socket_mut() self.inner.get_socket_mut()
} }
@@ -791,7 +792,7 @@ impl tokio::io::AsyncWrite for BindDataPlane {
} }
} }
/// The BIND consumer session (the `bind()` entry namespace — the POC /// The BIND consumer session (the `bind()` entry namespace, the POC
/// #5 shape): opens the channel, negotiates, sends BIND, reads /// #5 shape): opens the channel, negotiates, sends BIND, reads
/// reply#1 (`bind`), then awaits reply#2 + the data plane /// reply#1 (`bind`), then awaits reply#2 + the data plane
/// (`await_inbound`). The session owns the adopted channel: on Drop /// (`await_inbound`). The session owns the adopted channel: on Drop
@@ -807,27 +808,29 @@ impl tokio::io::AsyncWrite for BindDataPlane {
/// Error surface: a refused BIND (the command gate's 0x02) or a /// Error surface: a refused BIND (the command gate's 0x02) or a
/// factory failure's reply#1 code surfaces from `bind` as /// factory failure's reply#1 code surfaces from `bind` as
/// [`SocksSessionError::Rfc`] (the adopted channel is torn down /// [`SocksSessionError::Rfc`] (the adopted channel is torn down
/// before the error returns — teardown-at-birth, the phantom-channel /// before the error returns; that is teardown-at-birth, the
/// property); accept-stage failures surface at `await_inbound` as /// phantom-channel property); accept-stage failures surface at
/// reply#2's mapped code ([`SocksSessionError::Rfc`]) — the channel /// `await_inbound` as reply#2's mapped code
/// ([`SocksSessionError::Rfc`]); the channel
/// is still open there (the listener existed; the session lives until /// is still open there (the listener existed; the session lives until
/// the app closes it; the inner is restored, see the take-guard /// the app closes it; the inner is restored, see the take-guard
/// below). /// below).
/// ///
/// The reply#2 phase is take-guarded (review 002 /// The reply#2 phase is take-guarded (review 002
/// C-1): the first `await_inbound` takes the inner stream — the /// C-1): the first `await_inbound` takes the inner stream (the
/// returned plane owns it (the `TakenInner` pairing posture; the /// returned plane owns it; the `TakenInner` pairing posture, the
/// session stays the teardown owner) — so a second call hits the /// session stays the teardown owner), so a second call hits the
/// `None` arm and returns `Ok(None)`: the session family's spent /// `None` arm and returns `Ok(None)`: the session family's spent
/// shape, with no re-read of the live data plane possible. A failed /// shape, with no re-read of the live data plane possible. A failed
/// read restores the inner (the phase is only consumed on success; /// read restores the inner (the phase is only consumed on success;
/// the producer ends the session on every post-reply#1 failure exit, /// the producer ends the session on every post-reply#1 failure exit,
/// so a re-call EOFs — never a live data-plane re-read either way). /// so a re-call EOFs and never does a live data-plane re-read either
/// way).
pub struct BindSession { pub struct BindSession {
channel_id: u32, channel_id: u32,
manager: Option<ChannelManager>, manager: Option<ChannelManager>,
inner: Option<Socks5Stream<BiStream>>, inner: Option<Socks5Stream<BiStream>>,
/// Reply#1's BND.ADDR — captured at `bind` (the forked client's /// Reply#1's BND.ADDR, captured at `bind` (the forked client's
/// `request()` returns it once; the session carries it for the /// `request()` returns it once; the session carries it for the
/// app server's out-of-band dial). /// app server's out-of-band dial).
bind_addr: Option<TargetAddr>, bind_addr: Option<TargetAddr>,
@@ -845,17 +848,17 @@ impl std::fmt::Debug for BindSession {
impl BindSession { impl BindSession {
/// BIND through a produced SOCKS5 resource: opens the channel /// BIND through a produced SOCKS5 resource: opens the channel
/// (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)` — the trivial /// (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`, the trivial
/// params, ADR-001), drives the no-auth handshake and the BIND /// params, ADR-001), drives the no-auth handshake and the BIND
/// request in-stream over the channel's `BiStream`, and reads /// request in-stream over the channel's `BiStream`, and reads
/// reply#1 (carrying BND.ADDR — the forked client `request()`'s /// reply#1 (carrying BND.ADDR, the forked client `request()`'s
/// normal path). The returned session owns the channel; the /// normal path). The returned session owns the channel; the
/// advertised address is [`BindReply1::bind_addr`]. /// advertised address is [`BindReply1::bind_addr`].
/// ///
/// Error surface: an open failure is [`SocksSessionError::Open`] /// Error surface: an open failure is [`SocksSessionError::Open`]
/// (the typed ADR-049 reasons; no channel was ever returned); a /// (the typed ADR-049 reasons; no channel was ever returned); a
/// refused BIND — the command gate's `ConnectionNotAllowed` 0x02 /// refused BIND (the command gate's `ConnectionNotAllowed` 0x02,
/// or a factory failure's mapped reply#1 code — surfaces the RFC /// or a factory failure's mapped reply#1 code) surfaces the RFC
/// reply code through [`SocksSessionError::Rfc`], and the adopted /// reply code through [`SocksSessionError::Rfc`], and the adopted
/// channel is torn down before the error returns (no phantom /// channel is torn down before the error returns (no phantom
/// channel). /// channel).
@@ -881,7 +884,7 @@ impl BindSession {
/// The cred-bearing front door's BIND re-origination (the /// The cred-bearing front door's BIND re-origination (the
/// token-carrying twin of [`Self::bind`]): the open rides the /// token-carrying twin of [`Self::bind`]): the open rides the
/// payload `auth_token` (CF-005 precedence (1) — the producer's /// payload `auth_token` (CF-005 precedence (1): the producer's
/// identity provider resolves the door's mapped identity; the /// identity provider resolves the door's mapped identity; the
/// BIND command gate reads `socks5:bind` from it, ADR-006), then /// BIND command gate reads `socks5:bind` from it, ADR-006), then
/// the same in-stream BIND phase model over the channel's /// the same in-stream BIND phase model over the channel's
@@ -915,12 +918,12 @@ impl BindSession {
} }
/// Reply#1's view: the advertised BND.ADDR (the app server dials /// Reply#1's view: the advertised BND.ADDR (the app server dials
/// it out-of-band — the RFC's original role for the address) and /// it out-of-band, the RFC's original role for the address) and
/// the raw pre-reply#2 stream ([`BindReply1::raw_mut`], the /// the raw pre-reply#2 stream ([`BindReply1::raw_mut`], the
/// front-door bridge seam). A **view**, not a reader (review 002 /// front-door bridge seam). A **view**, not a reader (review 002
/// D-1's one-reader decision): the one normative reply#2 reader /// D-1's one-reader decision): the one normative reply#2 reader
/// is [`Self::await_inbound`]; the handle can be re-borrowed /// is [`Self::await_inbound`]; the handle can be re-borrowed
/// freely — it carries no read phase. /// freely; it carries no read phase.
pub fn reply1(&mut self) -> Option<BindReply1<'_>> { pub fn reply1(&mut self) -> Option<BindReply1<'_>> {
let inner = self.inner.as_mut()?; let inner = self.inner.as_mut()?;
Some(BindReply1 { Some(BindReply1 {
@@ -931,22 +934,22 @@ impl BindSession {
/// Await the inbound accept (reply#2) and take the pass-through /// Await the inbound accept (reply#2) and take the pass-through
/// data plane. The one normative reply#2 reader (review 002 C-1 + /// data plane. The one normative reply#2 reader (review 002 C-1 +
/// D-1): the phase is **take-guarded** — the read consumes the /// D-1): the phase is **take-guarded**: the read consumes the
/// session's inner stream (the plane owns it; the session stays /// session's inner stream (the plane owns it; the session stays
/// the teardown owner) — so a second call cannot re-read the /// the teardown owner), so a second call cannot re-read the
/// live data plane. Reply#2 is hand-read with the forked /// live data plane. Reply#2 is hand-read with the forked
/// `read_address` codec — reply#1's codec applied again, no /// `read_address` codec (reply#1's codec applied again, no
/// second codec (POC #5's `read_reply2` sketch; ADR-011's /// second codec (POC #5's `read_reply2` sketch; ADR-011's
/// rationale). /// rationale).
/// ///
/// `Ok(None)` after the plane was taken (the spent-session /// `Ok(None)` after the plane was taken (the spent-session
/// posture — the doc claim this guard makes true). A failed read /// posture, the doc claim this guard makes true). A failed read
/// restores the inner (the phase is not consumed on failure): the /// restores the inner (the phase is not consumed on failure): the
/// channel stays owned (reply#1 succeeded — the listener existed; /// channel stays owned (reply#1 succeeded; the listener existed;
/// reap via `close`/`Drop`), the pipe stays reachable /// reap via `close`/`Drop`), the pipe stays reachable
/// (`split`), and a re-call re-reads against the already-ended /// (`split`), and a re-call re-reads against the already-ended
/// session (the producer ends it on every post-reply#1 failure /// session (the producer ends it on every post-reply#1 failure
/// exit — the read EOFs; never a live data-plane re-read). /// exit, and the read EOFs (never a live data-plane re-read).
pub async fn await_inbound(&mut self) -> Result<Option<BindDataPlane>, SocksSessionError> { pub async fn await_inbound(&mut self) -> Result<Option<BindDataPlane>, SocksSessionError> {
let Some(mut inner) = self.inner.take() else { let Some(mut inner) = self.inner.take() else {
return Ok(None); return Ok(None);
@@ -961,8 +964,8 @@ impl BindSession {
} }
/// The pass-through pipe as split read/write halves (0 B framing /// The pass-through pipe as split read/write halves (0 B framing
/// — raw bytes to/from the accepted peer). Valid in the reply#1 /// raw bytes to/from the accepted peer). Valid in the reply#1
/// phase (pre-reply#2 bytes ride the same channel — the /// phase (pre-reply#2 bytes ride the same channel, the
/// front-door bridge seam); post-[`Self::await_inbound`] the /// front-door bridge seam); post-[`Self::await_inbound`] the
/// plane owns the stream (the take-guard), so the data plane's /// plane owns the stream (the take-guard), so the data plane's
/// halves ride `BindDataPlane`/its `into_inner` instead. `None` /// halves ride `BindDataPlane`/its `into_inner` instead. `None`
@@ -985,9 +988,9 @@ impl BindSession {
/// The adopted channel's teardown (`teardown_channel` returns a /// The adopted channel's teardown (`teardown_channel` returns a
/// producer-side pump-task handle the caller may abort after the /// producer-side pump-task handle the caller may abort after the
/// drain — the channels-call path's shape). The handle is /// drain, the channels-call path's shape). The handle is
/// deliberately dropped here: the producer-side pump finishes via /// deliberately dropped here: the producer-side pump finishes via
/// EOF propagation (the graceful shape — aborting would cut /// EOF propagation (the graceful shape, since aborting would cut
/// mid-flight data); `clear_all` aborts instead (the substrate's /// mid-flight data); `clear_all` aborts instead (the substrate's
/// abrupt path). /// abrupt path).
fn reap(&mut self) -> bool { fn reap(&mut self) -> bool {
@@ -1000,7 +1003,7 @@ impl BindSession {
impl Drop for BindSession { impl Drop for BindSession {
/// Sync-reap the adopted channel entry (`teardown_channel` is /// Sync-reap the adopted channel entry (`teardown_channel` is
/// sync — best-effort; `Drop` cannot await) and drop the pipe /// sync, best-effort; `Drop` cannot await) and drop the pipe
/// (the mux's implicit-EOF sentinel EOFs the producer's pump). /// (the mux's implicit-EOF sentinel EOFs the producer's pump).
/// Dropping without `close` never leaks the channel entry. /// Dropping without `close` never leaks the channel entry.
fn drop(&mut self) { fn drop(&mut self) {
@@ -1012,14 +1015,15 @@ impl Drop for BindSession {
/// Map a datagram-frame decode error ([`WireError`]) into the RFC /// Map a datagram-frame decode error ([`WireError`]) into the RFC
/// vocabulary the session surface carries: the address-encoding /// vocabulary the session surface carries: the address-encoding
/// refusal is the forked [`AddrError`] class (the same sub-layout /// refusal is the forked [`AddrError`] class (the same sub-layout
/// the forked codec validates — one vocabulary), the protocol-garbage /// the forked codec validates, hence one vocabulary), the
/// protocol-garbage
/// shapes (bad ATYP / a short frame / non-UTF-8 domain / a `len == 0` /// shapes (bad ATYP / a short frame / non-UTF-8 domain / a `len == 0`
/// frame) surface as the RFC UDP-header error class /// frame) surface as the RFC UDP-header error class
/// ([`UdpHeaderError`]) — the post-reply-garbage semantic, the /// ([`UdpHeaderError`]), mirroring the post-reply-garbage semantic as
/// consumer-side mirror of the producer stage's /// the consumer-side mirror of the producer stage's
/// `UndecodableFrame` exit. The oversize family is unreachable on /// `UndecodableFrame` exit. The oversize family is unreachable on
/// the receive path (the length codec's frame-time bound makes a /// the receive path (the length codec's frame-time bound makes a
/// declared length undecodable only via the garbage shapes) — it /// declared length undecodable only via the garbage shapes); it maps
/// maps through the UDP-header class too (fail-loud either way). /// maps through the UDP-header class too (fail-loud either way).
fn wire_error(err: WireError) -> SocksSessionError { fn wire_error(err: WireError) -> SocksSessionError {
match err { match err {
@@ -1032,10 +1036,10 @@ fn wire_error(err: WireError) -> SocksSessionError {
} }
/// Read reply#2 off the raw stream (the RFC's second reply: VER, REP, /// Read reply#2 off the raw stream (the RFC's second reply: VER, REP,
/// RSV, ATYP, addr, port — the POC #5 `read_reply2` sketch): the 4-byte /// RSV, ATYP, addr, port, the POC #5 `read_reply2` sketch): the 4-byte
/// head, the version pin, the reply code mapped through the forked /// head, the version pin, the reply code mapped through the forked
/// `ReplyError::from_u8`, then the address via the forked public /// `ReplyError::from_u8`, then the address via the forked public
/// `read_address` codec — reply#1's codec applied again, **no second /// `read_address` codec (reply#1's codec applied again, **no second
/// codec** (ADR-011's rationale). /// codec** (ADR-011's rationale).
async fn read_reply2<T>(inner: &mut Socks5Stream<T>) -> Result<TargetAddr, SocksSessionError> async fn read_reply2<T>(inner: &mut Socks5Stream<T>) -> Result<TargetAddr, SocksSessionError>
where where
@@ -1060,16 +1064,16 @@ where
/// The cred-bearing front door's channel surface (the /// The cred-bearing front door's channel surface (the
/// token-carrying twin of a `ChannelClient`'s open path): wraps the /// token-carrying twin of a `ChannelClient`'s open path): wraps the
/// client's channel-0 [`CallConnection`] so an open call can carry the /// client's channel-0 [`CallConnection`] so an open call can carry the
/// payload `auth_token` (CF-005 precedence (1) — the producer-side /// payload `auth_token` (CF-005 precedence (1): the producer-side
/// dispatch resolves the token through its /// dispatch resolves the token through its
/// `ServingConfig.identity_provider` and runs the ACL gate against the /// `ServingConfig.identity_provider` and runs the ACL gate against the
/// resolved identity, ADR-017 §7). The client's call connection is /// resolved identity, ADR-017 §7). The client's call connection is
/// taken once at construction (the client's own calling surface ends /// taken once at construction (the client's own calling surface ends
/// — a door client is a door client); the manager stays shared for /// a door client is a door client); the manager stays shared for
/// `adopt_channel`/teardown. /// `adopt_channel`/teardown.
/// ///
/// The token is the credential table's *identity id* (the resolution /// The token is the credential table's *identity id* (the resolution
/// seam's vocabulary — the SOCKS password itself never rides it, and /// seam's vocabulary; the SOCKS password itself never rides it, and
/// the vanilla client never sees the identity). The door's producer /// the vanilla client never sees the identity). The door's producer
/// resolves `token → Identity` through its /// resolves `token → Identity` through its
/// `ServingConfig.identity_provider`; the identity's scopes govern the /// `ServingConfig.identity_provider`; the identity's scopes govern the
@@ -1087,7 +1091,7 @@ impl TokenClient {
/// ///
/// [`TokenClientError::CallConnectionTaken`] when the client's /// [`TokenClientError::CallConnectionTaken`] when the client's
/// call connection was already taken (a client is a single /// call connection was already taken (a client is a single
/// calling surface — the door-only contract; convention 2: no /// calling surface, the door-only contract; convention 2: no
/// panic path on a double wrap). /// panic path on a double wrap).
pub async fn wrap(client: &ChannelClient) -> Result<Self, TokenClientError> { pub async fn wrap(client: &ChannelClient) -> Result<Self, TokenClientError> {
let call = client let call = client
@@ -1100,7 +1104,7 @@ impl TokenClient {
}) })
} }
/// The manager (adopt/teardown — the token sessions' prologue uses /// The manager (adopt/teardown; the token sessions' prologue uses
/// it; the sessions' teardown owns it per session). /// it; the sessions' teardown owns it per session).
pub fn manager(&self) -> &ChannelManager { pub fn manager(&self) -> &ChannelManager {
&self.manager &self.manager
@@ -1109,9 +1113,9 @@ impl TokenClient {
/// The raw open call with the `auth_token` payload (CF-005 /// The raw open call with the `auth_token` payload (CF-005
/// precedence (1)): the payload shape mirrors /// precedence (1)): the payload shape mirrors
/// [`ChannelClient::call_open_op`]'s `call.requested` envelope with /// [`ChannelClient::call_open_op`]'s `call.requested` envelope with
/// the token added (ADR-017 §7 — the hub-forwarding path's shape). /// the token added (ADR-017 §7, the hub-forwarding path's shape).
/// Returns the adopted `channel_id` (the reply's `channel_id` /// Returns the adopted `channel_id` (the reply's `channel_id`
/// extracted — the adopt is the caller's next step, mirroring /// extracted; the adopt is the caller's next step, mirroring
/// `open_channel`'s two halves). /// `open_channel`'s two halves).
pub async fn open_with_token(&self, token: Vec<u8>) -> Result<u32, SocksOpenError> { pub async fn open_with_token(&self, token: Vec<u8>) -> Result<u32, SocksOpenError> {
let payload = serde_json::json!({ let payload = serde_json::json!({
@@ -1133,11 +1137,11 @@ impl TokenClient {
} }
/// Why a `TokenClient` refused to construct (the typed construction /// Why a `TokenClient` refused to construct (the typed construction
/// error — the client's calling surface was already taken; /// error: the client's calling surface was already taken;
/// convention 2: no panic path). /// convention 2: no panic path).
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum TokenClientError { pub enum TokenClientError {
#[error("the channel client's call connection was already taken (a client is one calling surface — the door-only contract)")] #[error("the channel client's call connection was already taken (a client is one calling surface, the door-only contract)")]
CallConnectionTaken, CallConnectionTaken,
} }
@@ -1171,7 +1175,7 @@ mod tests {
async fn spent_bind_await_inbound_returns_the_spent_shape() { async fn spent_bind_await_inbound_returns_the_spent_shape() {
// The C-1 take-guard's spent arm: a session whose inner is // The C-1 take-guard's spent arm: a session whose inner is
// gone (taken at the first read / the teardown) returns the // gone (taken at the first read / the teardown) returns the
// family's `Ok(None)` — no re-read, no panic. // family's `Ok(None)`: no re-read, no panic.
let mut session = BindSession { let mut session = BindSession {
channel_id: 9, channel_id: 9,
manager: None, manager: None,
@@ -1209,7 +1213,7 @@ mod tests {
inner: None, inner: None,
reply_addr: None, reply_addr: None,
}; };
assert!(!session.reap(), "no manager — nothing reaped"); assert!(!session.reap(), "no manager, so nothing was reaped");
assert!(session.split().is_none(), "no pipe after teardown"); assert!(session.split().is_none(), "no pipe after teardown");
} }
@@ -1217,7 +1221,7 @@ mod tests {
fn spent_session_into_inner_is_an_err_not_a_panic() { fn spent_session_into_inner_is_an_err_not_a_panic() {
// The `expect` the review removed (R-2): the spent-session // The `expect` the review removed (R-2): the spent-session
// path is reachable (a session whose inner is gone), so it // path is reachable (a session whose inner is gone), so it
// must be a typed `Err` — no panic path. // must be a typed `Err`, with no panic path.
let session = Socks5ChannelSession { let session = Socks5ChannelSession {
channel_id: 9, channel_id: 9,
manager: None, manager: None,
@@ -1270,7 +1274,7 @@ mod tests {
pending: Default::default(), pending: Default::default(),
relay_addr: None, relay_addr: None,
}; };
assert!(!session.reap(), "no manager — nothing reaped"); assert!(!session.reap(), "no manager, so nothing was reaped");
assert!(session.split().is_none(), "no stream after teardown"); assert!(session.split().is_none(), "no stream after teardown");
assert!(session.relay_addr().is_none()); assert!(session.relay_addr().is_none());
} }
@@ -1279,7 +1283,8 @@ mod tests {
fn spent_associate_into_inner_is_an_err_not_a_panic() { fn spent_associate_into_inner_is_an_err_not_a_panic() {
// The `expect` the review removed (R-2): the spent-session // The `expect` the review removed (R-2): the spent-session
// path is reachable (`into_inner` twice), so it must be a // path is reachable (`into_inner` twice), so it must be a
// typed `Err` — no panic path. The teardown owner rides back // typed `Err`, with no panic path. The teardown owner rides
// back
// to the caller either way. // to the caller either way.
let session = AssociateSession { let session = AssociateSession {
channel_id: 9, channel_id: 9,
@@ -1301,7 +1306,7 @@ mod tests {
use tokio::io::AsyncWriteExt as _; use tokio::io::AsyncWriteExt as _;
// The frame-source side: two frames, the first one split // The frame-source side: two frames, the first one split
// across two writes (the 2-byte-prefix chunk shape — the POC // across two writes (the 2-byte-prefix chunk shape, the POC
// finding's trigger) and the second batched into the same // finding's trigger) and the second batched into the same
// read batch. The session's reader state must survive the // read batch. The session's reader state must survive the
// call boundary: the first call returns the first datagram // call boundary: the first call returns the first datagram
@@ -1338,7 +1343,7 @@ mod tests {
assert_eq!(src, addr); assert_eq!(src, addr);
assert_eq!(&second[..], b"second"); assert_eq!(&second[..], b"second");
// EOF: both frames consumed, the queue empty — `None`. // EOF: both frames consumed, and the queue empty means `None`.
assert!(session.recv_datagram().await.expect("eof").is_none()); assert!(session.recv_datagram().await.expect("eof").is_none());
} }
@@ -1412,7 +1417,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn send_datagram_on_a_spent_stream_is_the_io_arm() { async fn send_datagram_on_a_spent_stream_is_the_io_arm() {
// The spent-stream `Io` arm (review 002 N-8): a session whose // The spent-stream `Io` arm (review 002 N-8): a session whose
// stream is gone errors loud through the typed I/O surface — // stream is gone errors loud through the typed I/O surface,
// the mirror of the covered spent-`recv` arm. // the mirror of the covered spent-`recv` arm.
let mut session = AssociateSession { let mut session = AssociateSession {
channel_id: 9, channel_id: 9,
@@ -1440,9 +1445,9 @@ mod tests {
); );
// The spent-session teardown surfaces: `shutdown` on a spent // The spent-session teardown surfaces: `shutdown` on a spent
// session is a no-op reap (no manager — nothing reaped). // session is a no-op reap (no manager, so nothing was reaped).
let shutdown = session.shutdown().await.expect("spent shutdown"); let shutdown = session.shutdown().await.expect("spent shutdown");
assert!(!shutdown, "no manager — nothing reaped"); assert!(!shutdown, "no manager, so nothing was reaped");
} }
#[tokio::test] #[tokio::test]
@@ -1458,7 +1463,7 @@ mod tests {
pending: Default::default(), pending: Default::default(),
relay_addr: None, relay_addr: None,
}; };
assert!(!session.close().await, "no manager — nothing reaped"); assert!(!session.close().await, "no manager, so nothing was reaped");
} }
#[tokio::test] #[tokio::test]
@@ -1466,8 +1471,9 @@ mod tests {
use tokio::io::AsyncWriteExt as _; use tokio::io::AsyncWriteExt as _;
// The mid-batch later-frame `Err` arm (review 002 N-8): the // The mid-batch later-frame `Err` arm (review 002 N-8): the
// first frame of a batch decodes `Ok`, a later one is garbage // first frame of a batch decodes `Ok`, a later one is
// — the batch is discarded with the session (fail-loud, never // garbage, and the batch is discarded with the session
// (fail-loud, never
// a partial batch delivered while the session stays open). // a partial batch delivered while the session stays open).
let (mut peer_writer, session_reader) = tokio::io::duplex(1024); let (mut peer_writer, session_reader) = tokio::io::duplex(1024);
let addr = TargetAddr::Ip("10.0.0.1:53".parse().unwrap()); let addr = TargetAddr::Ip("10.0.0.1:53".parse().unwrap());
@@ -1496,7 +1502,7 @@ mod tests {
"the decode garbage rides the UDP-header class: {err:?}" "the decode garbage rides the UDP-header class: {err:?}"
); );
// The pending `Ok` was dropped with the session — the queue // The pending `Ok` was dropped with the session; the queue
// stays empty and the stream is spent for further reads (the // stays empty and the stream is spent for further reads (the
// error ended the caller's loop, the fail-loud posture). // error ended the caller's loop, the fail-loud posture).
assert!(session.pending.is_empty()); assert!(session.pending.is_empty());
@@ -1507,7 +1513,7 @@ mod tests {
// The `Ok` arm of `AssociateSession::into_inner` (review 002 // The `Ok` arm of `AssociateSession::into_inner` (review 002
// N-8): the taken stream pairs with the session that stays the // N-8): the taken stream pairs with the session that stays the
// teardown owner (the `into_inner_pairs_the_pipe_with_the_ // teardown owner (the `into_inner_pairs_the_pipe_with_the_
// teardown_owner` shape's ASSOCIATE mirror) — and a second // teardown_owner` shape's ASSOCIATE mirror), and a second
// take is the spent shape. // take is the spent shape.
let (_write_peer, session_writer) = tokio::io::duplex(64); let (_write_peer, session_writer) = tokio::io::duplex(64);
let (read_peer, session_reader) = tokio::io::duplex(64); let (read_peer, session_reader) = tokio::io::duplex(64);
@@ -1579,8 +1585,9 @@ mod tests {
// The front-door-bridge surface (consumer.md) on a *live* // The front-door-bridge surface (consumer.md) on a *live*
// stream: the halves borrow the session and the raw framed // stream: the halves borrow the session and the raw framed
// bytes pass through both directions (0 B framing — the door's // bytes pass through both directions (0 B framing; the
// RFC header codec would own the frames at the boundary). // door's RFC header codec would own the frames at the
// boundary).
let addr = TargetAddr::Ip("10.0.0.1:53".parse().unwrap()); let addr = TargetAddr::Ip("10.0.0.1:53".parse().unwrap());
let framed = wire_frame(&addr, b"split").unwrap().to_vec(); let framed = wire_frame(&addr, b"split").unwrap().to_vec();
@@ -1610,7 +1617,7 @@ mod tests {
assert_eq!(len, 7 + 5, "one v4-address datagram frame"); assert_eq!(len, 7 + 5, "one v4-address datagram frame");
drop(read); drop(read);
// The write half passes through (the bridge seam's shape) — // The write half passes through (the bridge seam's shape), so
// the peer receives the exact bytes. // the peer receives the exact bytes.
write.write_all(&framed).await.unwrap(); write.write_all(&framed).await.unwrap();
write.flush().await.unwrap(); write.flush().await.unwrap();
@@ -1646,7 +1653,7 @@ mod tests {
use tokio::io::AsyncWriteExt as _; use tokio::io::AsyncWriteExt as _;
// The BIND front-door-bridge surface (consumer.md): the split // The BIND front-door-bridge surface (consumer.md): the split
// halves in the reply#1 phase — pre-reply#2 bytes ride the // halves in the reply#1 phase, so pre-reply#2 bytes ride the
// same channel (the doc's claim, exercised on a live stream). // same channel (the doc's claim, exercised on a live stream).
let (mut peer_writer, session_reader) = tokio::io::duplex(1024); let (mut peer_writer, session_reader) = tokio::io::duplex(1024);
peer_writer.write_all(b"raw").await.unwrap(); peer_writer.write_all(b"raw").await.unwrap();
@@ -1676,7 +1683,7 @@ mod tests {
// The take-guard is untouched: the reader still reads the // The take-guard is untouched: the reader still reads the
// reply#2 phase (the split halves were a view, not a // reply#2 phase (the split halves were a view, not a
// consumer) — the dropped peer EOFs it loud, never the spent // consumer); the dropped peer EOFs it loud, never the spent
// shape. // shape.
let outcome = session.await_inbound().await; let outcome = session.await_inbound().await;
assert!( assert!(
@@ -1692,7 +1699,7 @@ mod tests {
// The BIND door's half-close rides the plane's poll_shutdown // The BIND door's half-close rides the plane's poll_shutdown
// passthrough (and poll_flush rides the write path): write → // passthrough (and poll_flush rides the write path): write →
// flush → shutdown — the bytes reach the peer and the EOF // flush → shutdown, so the bytes reach the peer and the EOF
// sentinel follows. // sentinel follows.
let (_session_writer_unused, session_reader) = tokio::io::duplex(64); let (_session_writer_unused, session_reader) = tokio::io::duplex(64);
let (mut peer_reader, session_writer) = tokio::io::duplex(64); let (mut peer_reader, session_writer) = tokio::io::duplex(64);
@@ -1747,7 +1754,7 @@ mod tests {
} }
/// A session over a raw duplex (the reader internals' test /// A session over a raw duplex (the reader internals' test
/// harness — the handshake-free path: `skip_auth` bypasses the /// harness, a handshake-free path: `skip_auth` bypasses the
/// greeting, no reply is expected, the datagram stage rides /// greeting, no reply is expected, the datagram stage rides
/// raw). /// raw).
fn build_test_session(session_end: tokio::io::DuplexStream) -> AssociateSession { fn build_test_session(session_end: tokio::io::DuplexStream) -> AssociateSession {
+136 -120
View File
@@ -1,5 +1,5 @@
//! The consumer's TCP front door (feature `local`, ADR-005/ADR-014; //! The consumer's TCP front door (feature `local`, ADR-005/ADR-014;
//! consumer.md §The Front Doors): the ssh `-D` local exposure — a real //! consumer.md §The Front Doors): the ssh `-D` local exposure, a real
//! `socks5://127.0.0.1:port` endpoint vanilla clients (curl, browsers, //! `socks5://127.0.0.1:port` endpoint vanilla clients (curl, browsers,
//! tun2proxy) point at. The door is the vanilla↔ACL boundary; the RFC //! tun2proxy) point at. The door is the vanilla↔ACL boundary; the RFC
//! codec lives only here (ADR-003's "codec at front doors only"). //! codec lives only here (ADR-003's "codec at front doors only").
@@ -9,13 +9,13 @@
//! - **The NoAuth door** (POC #1's raw-pipe shape): per accepted //! - **The NoAuth door** (POC #1's raw-pipe shape): per accepted
//! connection, a literal raw pipe onto a channel `BiStream` //! connection, a literal raw pipe onto a channel `BiStream`
//! (`copy_bidirectional`, **zero protocol translation**). The //! (`copy_bidirectional`, **zero protocol translation**). The
//! producer runs the whole RFC conversation through the pipe — the //! producer runs the whole RFC conversation through the pipe, and
//! door adds nothing. The loopback-only / wrapper-aware shape. //! the door adds nothing. The loopback-only / wrapper-aware shape.
//! - **The cred-bearing door** (RFC 1929): the door terminates the //! - **The cred-bearing door** (RFC 1929): the door terminates the
//! local RFC conversation itself — the forked //! local RFC conversation itself, using the forked
//! `accept_password_auth` shape (`start` → //! `accept_password_auth` shape (`start` →
//! `negotiate_auth(&[PasswordAuthentication])` → //! `negotiate_auth(&[PasswordAuthentication])` →
//! `read_username_password` → check → `accept`/`reject`) — then //! `read_username_password` → check → `accept`/`reject`), then
//! maps the accepted credential through the **static config table** //! maps the accepted credential through the **static config table**
//! (`user:pass →` channel identity, ADR-005) and **re-originates //! (`user:pass →` channel identity, ADR-005) and **re-originates
//! the SOCKS conversation in-band** via the token-carrying consumer //! the SOCKS conversation in-band** via the token-carrying consumer
@@ -33,64 +33,66 @@
//! mapping yields an alkcall [`Identity`] (scopes included); the //! mapping yields an alkcall [`Identity`] (scopes included); the
//! per-credential identity rides the open-op call's `auth_token` //! per-credential identity rides the open-op call's `auth_token`
//! payload (CF-005 precedence (1): payload `auth_token` → //! payload (CF-005 precedence (1): payload `auth_token` →
//! `identity_provider` resolution — alkcall `dispatch:: //! `identity_provider` resolution, alkcall `dispatch::
//! resolve_identity`, ADR-017 §7), never a per-credential //! resolve_identity`, ADR-017 §7), never a per-credential
//! `ChannelClient`. The token is the mapped identity's id — the //! `ChannelClient`. The token is the mapped identity's id, the
//! resolution seam's vocabulary; the password itself never rides it. //! resolution seam's vocabulary; the password itself never rides it.
//! The producer resolves it through its `ServingConfig //! The producer resolves it through its `ServingConfig
//! .identity_provider` and the ACL gate reads the resolved scopes //! .identity_provider` and the ACL gate reads the resolved scopes
//! exactly as for any per-call opener identity (CF-005/CF-006). The //! exactly as for any per-call opener identity (CF-005/CF-006). The
//! door checks credentials itself — no forked `AuthMethod` impl //! door checks credentials itself, with no forked `AuthMethod` impl
//! (ADR-005). //! (ADR-005).
//! //!
//! **The accept path owns the idle defense** (ADR-014): an accepted //! **The accept path owns the idle defense** (ADR-014): an accepted
//! connection that sends no first byte within the configured window //! connection that sends no first byte within the configured window
//! is closed door-side (an accepted socket is not a channel — the cap //! is closed door-side (an accepted socket is not a channel, so the
//! cap
//! regime does not bound it); plus the max-concurrent-sessions cap //! regime does not bound it); plus the max-concurrent-sessions cap
//! (an accept beyond the bound is refused door-side, no channel //! (an accept beyond the bound is refused door-side, no channel
//! opened). The two v1 knobs are constructor config. //! opened). The two v1 knobs are constructor config.
//! //!
//! **The BIND door**: the same listener shape; a vanilla BIND //! **The BIND door**: the same listener shape; a vanilla BIND
//! conversation rides [`BindSession`] (reply#1's advertised address //! conversation rides [`BindSession`] (reply#1's advertised address
//! passes through to the vanilla client unchanged — the conversation //! passes through to the vanilla client unchanged, since the
//! is the client's own; the door adds nothing, consumer.md §The BIND //! conversation is the client's own; the door adds nothing,
//! door's reply#2 fidelity). The BIND door rides the same door //! consumer.md §The BIND door's reply#2 fidelity). The BIND door rides
//! variant split (NoAuth raw-pipe vs cred-bearing re-origination). //! the same door variant split (NoAuth raw-pipe vs cred-bearing
//! re-origination).
//! //!
//! Wiring (consumer.md §Wiring): `FrontDoor::builder() //! Wiring (consumer.md §Wiring): `FrontDoor::builder()
//! .credentials(cred_table).bind("127.0.0.1:1080").build()` → //! .credentials(cred_table).bind("127.0.0.1:1080").build()` →
//! `serve(channel_client)` — the NoAuth door omits `.credentials`. //! `serve(channel_client)`; the NoAuth door omits `.credentials`.
//! `serve` runs the accept loop; per-connection failures are logged + //! `serve` runs the accept loop; per-connection failures are logged +
//! isolated (the loop continues — the alktunnels per-accept isolation //! isolated (the loop continues, per the alktunnels per-accept
//! posture). //! isolation posture).
//! //!
//! **The UDP door** ([`UdpFrontDoor`], the POC #2 test-8 shape): the //! **The UDP door** ([`UdpFrontDoor`], the POC #2 test-8 shape): the
//! ASSOCIATE front door — a real UDP socket (the relay socket; the //! ASSOCIATE front door, a real UDP socket (the relay socket; the
//! vanilla client `sendto()`s it) bridged onto one in-band //! vanilla client `sendto()`s it) bridged onto one in-band
//! association. The RFC UDP-header codec lives ONLY here (ADR-003's //! association. The RFC UDP-header codec lives ONLY here (ADR-003's
//! codec-at-doors-only): inbound datagrams decode through //! codec-at-doors-only): inbound datagrams decode through
//! `parse_udp_request` and re-send in-band as `{addr, data}` frames //! `parse_udp_request` and re-send in-band as `{addr, data}` frames
//! (`send_datagram`); replies encode through `new_udp_header` and go //! (`send_datagram`); replies encode through `new_udp_header` and go
//! back to the client's source address. **FRAG ≠ 0 → refuse** (the //! back to the client's source address. **FRAG ≠ 0 → refuse** (the
//! datagram is dropped — the v1 posture; fragmentation cannot be //! datagram is dropped, the v1 posture; fragmentation cannot be
//! expressed in-band, and the association stays healthy). The //! expressed in-band, and the association stays healthy). The
//! datagram pump is the two-pump shape (one pump per direction, //! datagram pump is the two-pump shape (one pump per direction,
//! joined — the ADR-050 contract). Zero payload translation. The //! joined, per the ADR-050 contract). Zero payload translation. The
//! association's lifetime is the channel's (ADR-003): the association //! association's lifetime is the channel's (ADR-003): the association
//! ends when the channel ends; the door's relay socket closes with //! ends when the channel ends; the door's relay socket closes with
//! it. The door-side idle bound (ADR-014): no datagrams from the //! it. The door-side idle bound (ADR-014): no datagrams from the
//! client within the configured window closes the association (a //! client within the configured window closes the association (a
//! relay socket is not a channel — the cap regime does not bound //! relay socket is not a channel; the cap regime does not bound
//! it). The reply's sentinel BND.ADDR is received and never used — //! it). The reply's sentinel BND.ADDR is received and never used,
//! the handle is the channel; the real relay address is the door's //! since the handle is the channel; the real relay address is the door's
//! own bound socket, which the vanilla client learns from the TCP //! own bound socket, which the vanilla client learns from the TCP
//! door's ASSOCIATE reply (the assembly layer wires the two; the UDP //! door's ASSOCIATE reply (the assembly layer wires the two; the UDP
//! door serves the datagram plane alone). The bind address is //! door serves the datagram plane alone). The bind address is
//! constructor config (consumer.md: "the UDP door's bind address is //! constructor config (consumer.md: "the UDP door's bind address is
//! assembly-layer configuration"). Surface shape (recorded per the //! assembly-layer configuration"). Surface shape (recorded per the
//! task spec): a **sibling** `UdpFrontDoor` with its own builder — //! task spec): a **sibling** `UdpFrontDoor` with its own builder; the
//! the UDP door shares no accept loop with the TCP door (UDP has no //! UDP door shares no accept loop with the TCP door (UDP has no
//! connections; one relay socket = one association) and the shared //! connections; one relay socket = one association), and the shared
//! shape would have forced a mode split on every knob. //! shape would have forced a mode split on every knob.
use std::collections::HashMap; use std::collections::HashMap;
@@ -114,7 +116,7 @@ use crate::wire::{DatagramReader, WireError};
use bytes::Bytes; use bytes::Bytes;
/// One static credential-table entry (ADR-005): `user:pass →` channel /// One static credential-table entry (ADR-005): `user:pass →` channel
/// identity. The table is the door's credential→identity mapping — /// identity. The table is the door's credential→identity mapping:
/// static, declarative, assembly-layer. /// static, declarative, assembly-layer.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct DoorCredential { pub struct DoorCredential {
@@ -123,17 +125,17 @@ pub struct DoorCredential {
/// The RFC 1929 password the vanilla client presents. /// The RFC 1929 password the vanilla client presents.
pub password: String, pub password: String,
/// The channel identity the accepted credential maps to (scopes /// The channel identity the accepted credential maps to (scopes
/// included — the open gate and the BIND command gate read them /// included; the open gate and the BIND command gate read them
/// producer-side). The identity's `id` doubles as the open-op /// producer-side). The identity's `id` doubles as the open-op
/// `auth_token` (the resolution seam's vocabulary — the password /// `auth_token` (the resolution seam's vocabulary: the password
/// never rides it). /// never rides it).
pub identity: Identity, pub identity: Identity,
} }
/// The credential→identity static config table (ADR-005): exact /// The credential→identity static config table (ADR-005): exact
/// `username`/`password` lookup. A miss rejects the RFC 1929 /// `username`/`password` lookup. A miss rejects the RFC 1929
/// conversation door-side (no channel opened — the ACL boundary held; /// conversation door-side (no channel opened, so the ACL boundary is
/// the vanilla client got the RFC 1929 rejection). /// held; the vanilla client got the RFC 1929 rejection).
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct CredentialTable { pub struct CredentialTable {
entries: HashMap<(String, String), Identity>, entries: HashMap<(String, String), Identity>,
@@ -169,7 +171,7 @@ impl CredentialTable {
} }
/// Why one accepted connection's serving task ended (the door's /// Why one accepted connection's serving task ended (the door's
/// per-connection teardown diagnostic — logged + isolated). /// per-connection teardown diagnostic, logged + isolated).
#[derive(Debug)] #[derive(Debug)]
enum DoorExit { enum DoorExit {
/// The vanilla client went away (clean end, both variants). /// The vanilla client went away (clean end, both variants).
@@ -177,7 +179,7 @@ enum DoorExit {
/// The data plane (local pipe ↔ in-band session) ended. /// The data plane (local pipe ↔ in-band session) ended.
PlaneEnded, PlaneEnded,
/// The local RFC half failed (a rejected credential, a malformed /// The local RFC half failed (a rejected credential, a malformed
/// conversation, protocol garbage) — the vanilla client's fault. /// conversation, protocol garbage); the vanilla client's fault.
LocalRfc(SocksError), LocalRfc(SocksError),
/// The in-band open failed (the producer refused the mapped /// The in-band open failed (the producer refused the mapped
/// identity, the target dial refused, the channel open errored). /// identity, the target dial refused, the channel open errored).
@@ -245,10 +247,10 @@ impl FrontDoorBuilder {
self self
} }
/// The local bind address — the door's explicit bind decision /// The local bind address, the door's explicit bind decision
/// (the never-binds posture: the door is the assembly layer's /// (the never-binds posture: the door is the assembly layer's
/// spelled-out choice; loopback is the documented posture). /// spelled-out choice; loopback is the documented posture).
/// Required: `.build()` is a `Result` — omitting it is the /// Required: `.build()` is a `Result`; omitting it is the
/// builder's typed construction error (convention 2: no panic /// builder's typed construction error (convention 2: no panic
/// path). /// path).
pub fn bind(mut self, addr: impl Into<SocketAddr>) -> Self { pub fn bind(mut self, addr: impl Into<SocketAddr>) -> Self {
@@ -259,7 +261,7 @@ impl FrontDoorBuilder {
/// The idle-first-byte window (ADR-014's accept-path defense /// The idle-first-byte window (ADR-014's accept-path defense
/// knob 1): an accepted connection that sends no first byte /// knob 1): an accepted connection that sends no first byte
/// within this window is closed door-side. The window covers the /// within this window is closed door-side. The window covers the
/// first byte of the local conversation — the NoAuth door's /// first byte of the local conversation: the NoAuth door's
/// greeting bytes (read producer-side through the pipe) and the /// greeting bytes (read producer-side through the pipe) and the
/// cred-bearing door's greeting (read by the door's own RFC 1929 /// cred-bearing door's greeting (read by the door's own RFC 1929
/// half). /// half).
@@ -269,11 +271,11 @@ impl FrontDoorBuilder {
} }
/// The max-concurrent-*connections* cap (ADR-014's accept-path /// The max-concurrent-*connections* cap (ADR-014's accept-path
/// defense knob 2; review 003 N-3's naming — the mechanism bounds /// defense knob 2; review 003 N-3's naming, for the mechanism
/// accepted connections, including ones past the idle gate that /// bounds accepted connections, including ones past the idle gate
/// are merely slow readers, not active RFC sessions): an accept /// that are merely slow readers, not active RFC sessions): an
/// beyond the bound is refused door-side — the socket is closed /// accept beyond the bound is refused door-side; the socket is
/// immediately, no channel opened. /// closed immediately, with no channel opened.
pub fn max_sessions(mut self, cap: usize) -> Self { pub fn max_sessions(mut self, cap: usize) -> Self {
self.max_sessions = Some(cap); self.max_sessions = Some(cap);
self self
@@ -291,20 +293,22 @@ impl FrontDoorBuilder {
} }
/// Why a door builder refused to construct (the builder's typed /// Why a door builder refused to construct (the builder's typed
/// construction error — a missing required field; convention 2: no /// construction error, i.e. a missing required field; convention 2:
/// no
/// panic path on an omitted `.bind`). /// panic path on an omitted `.bind`).
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum DoorBuildError { pub enum DoorBuildError {
#[error("the door requires a bind address (.bind) — the bind decision is never implied")] #[error("the door requires a bind address (.bind); the bind decision is never implied")]
MissingBindAddr, MissingBindAddr,
} }
impl FrontDoor { impl FrontDoor {
/// Bind the configured address and run the accept loop /// Bind the configured address and run the accept loop
/// (consumer.md §Wiring: `door.serve(channel_client)`). Per- /// (consumer.md §Wiring: `door.serve(channel_client)`). Per-
/// connection failures are logged + isolated — the loop continues /// connection failures are logged + isolated, and the loop
/// (the alktunnels per-accept isolation posture). Runs until the /// continues (the alktunnels per-accept isolation posture). Runs
/// listener fails fatally; the caller owns the task's lifetime. /// until the listener fails fatally; the caller owns the task's
/// lifetime.
pub async fn serve(self, client: Arc<ChannelClient>) -> std::io::Result<()> { pub async fn serve(self, client: Arc<ChannelClient>) -> std::io::Result<()> {
let listener = TcpListener::bind(self.bind_addr).await?; let listener = TcpListener::bind(self.bind_addr).await?;
tracing::info!(bind = %self.bind_addr, "front door: listening"); tracing::info!(bind = %self.bind_addr, "front door: listening");
@@ -312,7 +316,7 @@ impl FrontDoor {
} }
/// Run the accept loop over an existing listener (the /// Run the accept loop over an existing listener (the
/// test/assembly seam — the same accept loop without the bind). /// test/assembly seam, the same accept loop without the bind).
pub async fn serve_on( pub async fn serve_on(
self, self,
listener: TcpListener, listener: TcpListener,
@@ -327,7 +331,7 @@ impl FrontDoor {
// transport, one call connection, many per-credential // transport, one call connection, many per-credential
// identities riding the open-op `auth_token` payloads). The // identities riding the open-op `auth_token` payloads). The
// NoAuth door keeps the client's own calling surface // NoAuth door keeps the client's own calling surface
// (`open_channel` — the raw-pipe shape never carries a // (`open_channel`; the raw-pipe shape never carries a
// token). // token).
let token_client = match credentials { let token_client = match credentials {
Some(_) => Some(Arc::new( Some(_) => Some(Arc::new(
@@ -348,11 +352,11 @@ impl FrontDoor {
}; };
// The session cap (ADR-014 knob 2): refuse beyond the // The session cap (ADR-014 knob 2): refuse beyond the
// bound — `try_acquire` keeps the accept loop free (a // bound; `try_acquire` keeps the accept loop free (a
// permit returns when a serving task ends and drops its // permit returns when a serving task ends and drops its
// guard). // guard).
let Ok(permit) = live.clone().try_acquire_owned() else { let Ok(permit) = live.clone().try_acquire_owned() else {
tracing::warn!(peer = %peer, "front door: session cap reached — refusing"); tracing::warn!(peer = %peer, "front door: session cap reached; refusing");
drop(stream); drop(stream);
continue; continue;
}; };
@@ -378,9 +382,10 @@ impl FrontDoor {
} }
/// Serve one accepted connection through the door variant split: the /// Serve one accepted connection through the door variant split: the
/// idle-first-byte gate first (ADR-014 knob 1 — the door's accept /// idle-first-byte gate first (ADR-014 knob 1, since the door's
/// path owns the defense; an accepted socket is not a channel), then /// accept
/// the variant. /// path owns the defense, and an accepted socket is not a channel),
/// then the variant.
async fn serve_connection( async fn serve_connection(
stream: TcpStream, stream: TcpStream,
peer: SocketAddr, peer: SocketAddr,
@@ -392,7 +397,8 @@ async fn serve_connection(
// The idle-first-byte defense: an accepted connection that sends // The idle-first-byte defense: an accepted connection that sends
// no first byte within the window is closed door-side (ADR-014's // no first byte within the window is closed door-side (ADR-014's
// slowloris sliver). The peek waits for readability without // slowloris sliver). The peek waits for readability without
// consuming — the variant's own protocol read picks the byte up. // consuming, and the variant's own protocol read picks the byte
// up.
let mut first = [0u8; 1]; let mut first = [0u8; 1];
match tokio::time::timeout(idle, stream.peek(&mut first)).await { match tokio::time::timeout(idle, stream.peek(&mut first)).await {
Ok(Ok(_)) => {} Ok(Ok(_)) => {}
@@ -404,14 +410,14 @@ async fn serve_connection(
} }
match (credentials, token_client) { match (credentials, token_client) {
// The NoAuth door: the literal raw pipe (POC #1's shape — // The NoAuth door: the literal raw pipe (POC #1's shape, with
// zero protocol translation). // zero protocol translation).
(None, _) => raw_pipe(stream, client).await, (None, _) => raw_pipe(stream, client).await,
// The cred-bearing door: RFC 1929 terminates locally, // The cred-bearing door: RFC 1929 terminates locally,
// re-origination rides the token sessions. // re-origination rides the token sessions.
(Some(table), Some(tokens)) => cred_bearing(stream, client, table, tokens).await, (Some(table), Some(tokens)) => cred_bearing(stream, client, table, tokens).await,
// Unreachable by construction (`serve_on` builds the pair) — // Unreachable by construction (`serve_on` builds the pair),
// the arm exists to keep the match exhaustive without a // so the arm exists to keep the match exhaustive without a
// panic path (convention 2). // panic path (convention 2).
(Some(_), None) => { (Some(_), None) => {
DoorExit::Open("the cred-bearing door's token surface was not built".to_string()) DoorExit::Open("the cred-bearing door's token surface was not built".to_string())
@@ -421,8 +427,8 @@ async fn serve_connection(
/// The NoAuth door's raw pipe (POC #1's `front_door_raw_pipe_vanilla_ /// The NoAuth door's raw pipe (POC #1's `front_door_raw_pipe_vanilla_
/// client` shape): open a channel, then `copy_bidirectional` the /// client` shape): open a channel, then `copy_bidirectional` the
/// accepted socket onto the channel `BiStream` — the producer runs /// accepted socket onto the channel `BiStream`, so the producer
/// the whole RFC conversation through the pipe; the door adds /// runs the whole RFC conversation through the pipe; the door adds
/// nothing. /// nothing.
async fn raw_pipe(mut stream: TcpStream, client: &Arc<ChannelClient>) -> DoorExit { async fn raw_pipe(mut stream: TcpStream, client: &Arc<ChannelClient>) -> DoorExit {
let (_channel_id, send, recv) = match client let (_channel_id, send, recv) = match client
@@ -451,8 +457,8 @@ async fn raw_pipe(mut stream: TcpStream, client: &Arc<ChannelClient>) -> DoorExi
/// The door never pipes raw post-auth: the vanilla client's command /// The door never pipes raw post-auth: the vanilla client's command
/// bytes were consumed by the door's own RFC half; the in-band path /// bytes were consumed by the door's own RFC half; the in-band path
/// re-speaks them from the parsed target. The password never crosses /// re-speaks them from the parsed target. The password never crosses
/// the channel as a SOCKS password (the in-band handshake is NoAuth — /// the channel as a SOCKS password (the in-band handshake is NoAuth,
/// the mapped identity is the authorization); the channel identity /// so the mapped identity is the authorization); the channel identity
/// never reaches the vanilla client. /// never reaches the vanilla client.
async fn cred_bearing( async fn cred_bearing(
mut stream: TcpStream, mut stream: TcpStream,
@@ -469,7 +475,7 @@ async fn cred_bearing(
// The in-band re-origination: the per-credential identity rides // The in-band re-origination: the per-credential identity rides
// the open-op call's `auth_token` payload (CF-005 precedence // the open-op call's `auth_token` payload (CF-005 precedence
// (1)). The token is the mapped identity's id — the resolution // (1)). The token is the mapped identity's id, the resolution
// seam's vocabulary; the password itself never rides it. // seam's vocabulary; the password itself never rides it.
let token = identity.id.as_bytes().to_vec(); let token = identity.id.as_bytes().to_vec();
@@ -486,7 +492,7 @@ async fn cred_bearing(
Err(err) => { Err(err) => {
// The in-band refusal's mapped code is re-encoded to // The in-band refusal's mapped code is re-encoded to
// the vanilla client (the RFC codec at the door // the vanilla client (the RFC codec at the door
// boundary — ADR-003): a dial refusal is reply#1's // boundary, ADR-003): a dial refusal is reply#1's
// code, never a bare EOF. // code, never a bare EOF.
write_refusal_reply(&mut stream, &err).await; write_refusal_reply(&mut stream, &err).await;
return DoorExit::Open(err.to_string()); return DoorExit::Open(err.to_string());
@@ -508,8 +514,9 @@ async fn cred_bearing(
pipe_bind_plane(stream, session).await pipe_bind_plane(stream, session).await
} }
// UDP ASSOCIATE through the TCP door is the UDP door's shape // UDP ASSOCIATE through the TCP door is the UDP door's shape
// (the datagram plane's RFC codec boundary — ADR-003), not // (the datagram plane's RFC codec boundary, ADR-003), not
// this one's: refuse RFC-faithfully — the vanilla client gets // this one's: refuse RFC-faithfully, so the vanilla client
// gets
// the refused reply through the door's own codec (no phantom // the refused reply through the door's own codec (no phantom
// data plane, no in-band session opened). // data plane, no in-band session opened).
Socks5Command::UDPAssociate => { Socks5Command::UDPAssociate => {
@@ -524,7 +531,7 @@ async fn cred_bearing(
} }
} }
/// The door's local RFC 1929 half (the forked surface — ADR-005's /// The door's local RFC 1929 half (the forked surface, ADR-005's
/// accept_password_auth shape): `negotiate_auth(&[ /// accept_password_auth shape): `negotiate_auth(&[
/// PasswordAuthentication])` → `read_username_password` → check /// PasswordAuthentication])` → `read_username_password` → check
/// through the static table → `accept`/`reject`, then /// through the static table → `accept`/`reject`, then
@@ -538,7 +545,8 @@ async fn local_rfc1929_half(
) -> Result<(Socks5Command, TargetAddr, Identity), DoorExit> { ) -> Result<(Socks5Command, TargetAddr, Identity), DoorExit> {
// The check closure is sync (`FnMut(String, String) -> bool` over // The check closure is sync (`FnMut(String, String) -> bool` over
// the forked `CheckResult`'s `bool` impl); the mapped identity is // the forked `CheckResult`'s `bool` impl); the mapped identity is
// recorded through a sync witness (a `std` mutex — the closure // recorded through a sync witness (a `std` mutex, since the
// closure
// runs once, synchronously). // runs once, synchronously).
let found: Arc<std::sync::Mutex<Option<Identity>>> = Arc::new(std::sync::Mutex::new(None)); let found: Arc<std::sync::Mutex<Option<Identity>>> = Arc::new(std::sync::Mutex::new(None));
let found_for_check = Arc::clone(&found); let found_for_check = Arc::clone(&found);
@@ -547,7 +555,7 @@ async fn local_rfc1929_half(
stream, stream,
|user, pass| { |user, pass| {
let mapped = table.lookup(&user, &pass).cloned(); let mapped = table.lookup(&user, &pass).cloned();
// A poisoned lock would mean a panic mid-check — the // A poisoned lock would mean a panic mid-check; the
// `unwrap_or_else(into_inner)` convention keeps the door // `unwrap_or_else(into_inner)` convention keeps the door
// alive (AGENTS.md convention 2). // alive (AGENTS.md convention 2).
let mut slot = found_for_check.lock().unwrap_or_else(|e| e.into_inner()); let mut slot = found_for_check.lock().unwrap_or_else(|e| e.into_inner());
@@ -573,22 +581,23 @@ async fn local_rfc1929_half(
} }
/// Pipe the re-originated CONNECT session: the vanilla reply (the /// Pipe the re-originated CONNECT session: the vanilla reply (the
/// in-band reply's BND.ADDR re-encoded — the RFC codec at the door /// in-band reply's BND.ADDR re-encoded (the RFC codec at the door
/// boundary only, ADR-003), then the data plane ↔ the accepted socket. /// boundary only, ADR-003), then the data plane ↔ the accepted socket.
/// The door already consumed the vanilla client's greeting + command /// The door already consumed the vanilla client's greeting + command
/// bytes; the post-auth bytes ride the session's data plane raw from /// bytes; the post-auth bytes ride the session's data plane raw from
/// here. The pump shape is the taken inner stream /// here. The pump shape is the taken inner stream
/// (`Socks5Stream<BiStream>` is itself `AsyncRead + AsyncWrite` — the /// (`Socks5Stream<BiStream>` is itself `AsyncRead + AsyncWrite`, the
/// forked client type's intended use) against the local socket via /// forked client type's intended use) against the local socket via
/// `copy_bidirectional`; the session stays the channel-teardown owner /// `copy_bidirectional`; the session stays the channel-teardown owner
/// (the `into_inner` pairing; dropped at the end — the EOF sentinel /// (the `into_inner` pairing; dropped at the end, so the EOF
/// sentinel
/// reaches the producer's pump). /// reaches the producer's pump).
async fn pipe_connect_plane( async fn pipe_connect_plane(
mut stream: TcpStream, mut stream: TcpStream,
session: crate::consumer::Socks5ChannelSession, session: crate::consumer::Socks5ChannelSession,
) -> DoorExit { ) -> DoorExit {
// The vanilla CONNECT reply: the in-band reply's BND.ADDR // The vanilla CONNECT reply: the in-band reply's BND.ADDR
// re-encoded (the vanilla client is waiting for it — the door's // re-encoded (the vanilla client is waiting for it; the door's
// RFC half consumed its local bytes; the in-band conversation is // RFC half consumed its local bytes; the in-band conversation is
// fresh and re-originated). The sentinel address is the // fresh and re-originated). The sentinel address is the
// RFC-compatibility surface the producer already chose; the door // RFC-compatibility surface the producer already chose; the door
@@ -621,7 +630,7 @@ async fn pipe_connect_plane(
/// Pipe the re-originated BIND session (the BIND door, cred-bearing /// Pipe the re-originated BIND session (the BIND door, cred-bearing
/// variant): reply#1's advertised address is re-encoded to the /// variant): reply#1's advertised address is re-encoded to the
/// vanilla client from the session's captured `bind_addr` (the /// vanilla client from the session's captured `bind_addr` (the
/// conversation is the vanilla client's own — the door's RFC half /// conversation is the vanilla client's own, and the door's RFC half
/// consumed the local greeting + command bytes, and the in-band /// consumed the local greeting + command bytes, and the in-band
/// session's reply#1 address passes through re-encoded; consumer.md /// session's reply#1 address passes through re-encoded; consumer.md
/// §The BIND door's reply#2 fidelity). The quiet wait rides /// §The BIND door's reply#2 fidelity). The quiet wait rides
@@ -633,7 +642,8 @@ async fn pipe_bind_plane(
mut session: crate::consumer::BindSession, mut session: crate::consumer::BindSession,
) -> DoorExit { ) -> DoorExit {
// Reply#1 (the vanilla form): the in-band reply's BND.ADDR // Reply#1 (the vanilla form): the in-band reply's BND.ADDR
// re-encoded — the advertised address passes through unchanged. // re-encoded, and the advertised address passes through
// unchanged.
let bind_addr = session.reply1().map(|reply1| reply1.bind_addr.clone()); let bind_addr = session.reply1().map(|reply1| reply1.bind_addr.clone());
let Some(bind_addr) = bind_addr else { let Some(bind_addr) = bind_addr else {
session.close().await; session.close().await;
@@ -651,7 +661,7 @@ async fn pipe_bind_plane(
/// Reply#1's vanilla wire form, re-encoded from the in-band reply's /// Reply#1's vanilla wire form, re-encoded from the in-band reply's
/// advertised address: an IP BND.ADDR rides the codec (`new_reply`); /// advertised address: an IP BND.ADDR rides the codec (`new_reply`);
/// a Domain BND.ADDR is the hand-written domain frame (the forked /// a Domain BND.ADDR is the hand-written domain frame (the forked
/// codec's `new_reply` takes a `SocketAddr` only — the domain form /// codec's `new_reply` takes a `SocketAddr` only; the domain form
/// has no codec path, so the frame is assembled here; the byte shape /// has no codec path, so the frame is assembled here; the byte shape
/// is pinned by the unit table, review 003 N-8). /// is pinned by the unit table, review 003 N-8).
fn bind_reply1_wire(bind_addr: &TargetAddr) -> Vec<u8> { fn bind_reply1_wire(bind_addr: &TargetAddr) -> Vec<u8> {
@@ -673,10 +683,10 @@ fn bind_reply1_wire(bind_addr: &TargetAddr) -> Vec<u8> {
} }
/// The BIND door's quiet wait + reply#2 + the data plane: await the /// The BIND door's quiet wait + reply#2 + the data plane: await the
/// inbound accept through the session (`await_inbound` — the one /// inbound accept through the session (`await_inbound`, the one
/// normative reply#2 reader), re-encode reply#2's peer to the vanilla /// normative reply#2 reader), re-encode reply#2's peer to the vanilla
/// client, then pipe the pass-through plane raw (the plane owns the /// client, then pipe the pass-through plane raw (the plane owns the
/// in-band stream — `BindDataPlane` impls `AsyncRead + AsyncWrite`, /// in-band stream: `BindDataPlane` impls `AsyncRead + AsyncWrite` for
/// the pass-through to the accepted peer; the session stays the /// the pass-through to the accepted peer; the session stays the
/// teardown owner, dropped at the end). /// teardown owner, dropped at the end).
async fn bind_quiet_and_pipe( async fn bind_quiet_and_pipe(
@@ -688,9 +698,9 @@ async fn bind_quiet_and_pipe(
Ok(None) => return DoorExit::PlaneEnded, Ok(None) => return DoorExit::PlaneEnded,
// An accept-stage refusal (the mapped reply#2 code): the // An accept-stage refusal (the mapped reply#2 code): the
// vanilla client gets that code re-encoded door-side (the RFC // vanilla client gets that code re-encoded door-side (the RFC
// codec at the boundary — never a bare EOF), then the // codec at the boundary, never a bare EOF), then the
// connection closes — the conversation ends where the in-band // connection closes, and the conversation ends where the
// one did. // in-band one did.
Err(err) => { Err(err) => {
write_refusal_reply(&mut stream, &err).await; write_refusal_reply(&mut stream, &err).await;
session.close().await; session.close().await;
@@ -717,7 +727,7 @@ async fn bind_quiet_and_pipe(
} }
/// Write one vanilla reply frame (the door's RFC codec at the /// Write one vanilla reply frame (the door's RFC codec at the
/// boundary — ADR-003): write + flush; a failed write is the client /// boundary, ADR-003): write + flush; a failed write is the client
/// gone. /// gone.
async fn write_vanilla_reply(stream: &mut TcpStream, reply: &[u8]) -> std::io::Result<()> { async fn write_vanilla_reply(stream: &mut TcpStream, reply: &[u8]) -> std::io::Result<()> {
stream.write_all(reply).await?; stream.write_all(reply).await?;
@@ -727,7 +737,7 @@ async fn write_vanilla_reply(stream: &mut TcpStream, reply: &[u8]) -> std::io::R
/// Re-encode an in-band session error's refusal to the vanilla client /// Re-encode an in-band session error's refusal to the vanilla client
/// (the door's RFC codec at the boundary): an error carrying an RFC /// (the door's RFC codec at the boundary): an error carrying an RFC
/// reply code gets that code as the command's reply (`Succeeded`-form /// reply code gets that code as the command's reply (`Succeeded`-form
/// encoding with the error's code + the placeholder BND.ADDR — a /// encoding with the error's code + the placeholder BND.ADDR, since a
/// refused reply carries a zero address per the RFC); any other error /// refused reply carries a zero address per the RFC); any other error
/// shape (open/transport/I-O) stays a bare close (nothing to encode). /// shape (open/transport/I-O) stays a bare close (nothing to encode).
/// Best-effort: a failed write means the client is gone either way. /// Best-effort: a failed write means the client is gone either way.
@@ -741,7 +751,7 @@ async fn write_refusal_reply(stream: &mut TcpStream, err: &SocksSessionError) {
} }
/// The UDP front door (feature `local`, the POC #2 test-8 shape): the /// The UDP front door (feature `local`, the POC #2 test-8 shape): the
/// ASSOCIATE door — a real UDP relay socket bridged onto one in-band /// ASSOCIATE door: a real UDP relay socket bridged onto one in-band
/// association. See the module docs for the shape and the codec /// association. See the module docs for the shape and the codec
/// boundary (ADR-003). /// boundary (ADR-003).
pub struct UdpFrontDoor { pub struct UdpFrontDoor {
@@ -753,7 +763,7 @@ pub struct UdpFrontDoor {
/// door's [`DoorExit`], for the datagram plane). /// door's [`DoorExit`], for the datagram plane).
#[derive(Debug)] #[derive(Debug)]
enum UdpDoorExit { enum UdpDoorExit {
/// The in-band association ended (channel EOF — the association /// The in-band association ended (channel EOF; the association
/// lifetime is the channel's, ADR-003). /// lifetime is the channel's, ADR-003).
AssociationEnded, AssociationEnded,
/// The client's idle bound fired (ADR-014's door-side defense). /// The client's idle bound fired (ADR-014's door-side defense).
@@ -783,7 +793,7 @@ impl UdpFrontDoor {
} }
/// The builder for [`UdpFrontDoor`]. The bind address is required /// The builder for [`UdpFrontDoor`]. The bind address is required
/// (the explicit bind decision — never implied); the idle bound is /// (the explicit bind decision, never implied); the idle bound is
/// the ADR-014 door-side defense knob. /// the ADR-014 door-side defense knob.
#[derive(Default)] #[derive(Default)]
pub struct UdpFrontDoorBuilder { pub struct UdpFrontDoorBuilder {
@@ -792,7 +802,7 @@ pub struct UdpFrontDoorBuilder {
} }
impl UdpFrontDoorBuilder { impl UdpFrontDoorBuilder {
/// The local bind address — the relay socket's explicit bind /// The local bind address, the relay socket's explicit bind
/// decision (loopback is the documented posture). /// decision (loopback is the documented posture).
pub fn bind(mut self, addr: impl Into<SocketAddr>) -> Self { pub fn bind(mut self, addr: impl Into<SocketAddr>) -> Self {
self.bind_addr = Some(addr.into()); self.bind_addr = Some(addr.into());
@@ -820,7 +830,7 @@ impl UdpFrontDoor {
/// Bind the configured relay address and serve the association /// Bind the configured relay address and serve the association
/// over it. Runs until the association ends (the in-band channel /// over it. Runs until the association ends (the in-band channel
/// EOF, the client idle bound, or a fatal I/O failure); the relay /// EOF, the client idle bound, or a fatal I/O failure); the relay
/// socket closes on return — the association lifetime is the /// socket closes on return, so the association lifetime is the
/// channel's, and the door's socket closes with it. /// channel's, and the door's socket closes with it.
pub async fn serve(self, client: Arc<ChannelClient>) -> std::io::Result<()> { pub async fn serve(self, client: Arc<ChannelClient>) -> std::io::Result<()> {
let socket = UdpSocket::bind(self.bind_addr).await?; let socket = UdpSocket::bind(self.bind_addr).await?;
@@ -829,17 +839,17 @@ impl UdpFrontDoor {
} }
/// Serve the association over an existing relay socket (the /// Serve the association over an existing relay socket (the
/// test/assembly seam — the same pump without the bind). /// test/assembly seam, the same pump without the bind).
/// ///
/// Posture (the peer-pin shape, OQ-SK2-09): the client's source /// Posture (the peer-pin shape, OQ-SK2-09): the client's source
/// **IP** is learned from the first inbound datagram and pinned for /// **IP** is learned from the first inbound datagram and pinned for
/// the association's lifetime; replies go to the **latest-seen /// the association's lifetime; replies go to the **latest-seen
/// port** from that IP (a NAT port rebind under a stable IP keeps /// port** from that IP (a NAT port rebind under a stable IP keeps
/// receiving). Datagrams from a source IP other than the pinned /// receiving). Datagrams from a source IP other than the pinned
/// one are dropped — RFC 1928's MUST ("MUST drop any datagrams /// one are dropped, per RFC 1928's MUST ("MUST drop any datagrams
/// arriving from any source IP address other than the one recorded /// arriving from any source IP address other than the one recorded
/// for the particular association"). The residual (a full client /// for the particular association"). The residual (a full client
/// IP change mid-association — a NAT *address* rebind) stops /// IP change mid-association (a NAT *address* rebind) stops
/// receiving replies by design: the RFC's expected-peer posture, /// receiving replies by design: the RFC's expected-peer posture,
/// revisitable per OQ-SK2-09. The loopback bind is the documented /// revisitable per OQ-SK2-09. The loopback bind is the documented
/// posture; a wildcard-bind deployment accepts the same-IP /// posture; a wildcard-bind deployment accepts the same-IP
@@ -853,8 +863,8 @@ impl UdpFrontDoor {
let mut association = AssociateSession::associate(client.as_ref()) let mut association = AssociateSession::associate(client.as_ref())
.await .await
.map_err(|err| std::io::Error::other(err.to_string()))?; .map_err(|err| std::io::Error::other(err.to_string()))?;
// The reply's sentinel BND.ADDR is received and never used — // The reply's sentinel BND.ADDR is received and never used,
// the handle is the channel (ADR-003). The real relay address // since the handle is the channel (ADR-003). The real relay address
// is this door's own bound socket; the vanilla client learns // is this door's own bound socket; the vanilla client learns
// it from the TCP door's ASSOCIATE reply (assembly-layer // it from the TCP door's ASSOCIATE reply (assembly-layer
// wiring). // wiring).
@@ -862,17 +872,18 @@ impl UdpFrontDoor {
// The datagram pump rides the association's split stream // The datagram pump rides the association's split stream
// halves: one pump owns the in-band write half (socket→ // halves: one pump owns the in-band write half (socket→
// channel), the other the read half (channel→socket) — no // channel), the other the read half (channel→socket), so no
// shared association lock (a mutex over the whole session // shared association lock holds (a mutex over the whole
// would deadlock: the channel read parks holding it while the // session would deadlock: the channel read parks holding it
// socket side's send needs it). The pump is inlined (the // while the socket side's send needs it). The pump is inlined
// (the
// halves borrow the association; the borrow ends before the // halves borrow the association; the borrow ends before the
// teardown below). // teardown below).
let Some((mut channel_read, mut channel_write)) = association.split() else { let Some((mut channel_read, mut channel_write)) = association.split() else {
return Ok(()); return Ok(());
}; };
let socket = Arc::new(socket); let socket = Arc::new(socket);
// The peer pin (OQ-SK2-09): the client's source IP — learned // The peer pin (OQ-SK2-09): the client's source IP, learned
// from the first inbound datagram and pinned; the reply port // from the first inbound datagram and pinned; the reply port
// refreshes to the latest-seen source port from that IP (the // refreshes to the latest-seen source port from that IP (the
// NAT-port-rebind shape). A source IP mismatch is the RFC's // NAT-port-rebind shape). A source IP mismatch is the RFC's
@@ -883,9 +894,9 @@ impl UdpFrontDoor {
Arc::new(std::sync::RwLock::new(None)); Arc::new(std::sync::RwLock::new(None));
// socket→channel: decode the RFC header (the codec boundary), // socket→channel: decode the RFC header (the codec boundary),
// refuse FRAG ≠ 0 (drop the datagram — the v1 posture), pin // refuse FRAG ≠ 0 (drop the datagram, the v1 posture), pin
// the peer IP + enforce the RFC's same-IP drop rule (the pin // the peer IP + enforce the RFC's same-IP drop rule (the pin
// reacts to *parsed* datagrams only — protocol garbage can // reacts to *parsed* datagrams only, so protocol garbage can
// never move it), refresh the reply port, send `{addr, data}` // never move it), refresh the reply port, send `{addr, data}`
// in-band. Each datagram resets the idle window. // in-band. Each datagram resets the idle window.
let s2c = { let s2c = {
@@ -944,8 +955,8 @@ impl UdpFrontDoor {
// latest-seen reply port. The association's end (the channel // latest-seen reply port. The association's end (the channel
// EOF) is this pump's clean exit. The decode state (the // EOF) is this pump's clean exit. The decode state (the
// incremental reader + the batched-datagram pending queue) // incremental reader + the batched-datagram pending queue)
// persists across pump iterations — the bridge owns it for the // persists across pump iterations, so the bridge owns it for
// association's lifetime. // the association's lifetime.
let c2s = { let c2s = {
let socket = Arc::clone(&socket); let socket = Arc::clone(&socket);
let client_peer = Arc::clone(&client_peer); let client_peer = Arc::clone(&client_peer);
@@ -984,7 +995,8 @@ impl UdpFrontDoor {
let _ = association.shutdown().await; let _ = association.shutdown().await;
// A socket I/O failure is the door's fatal outcome (surfaced to // A socket I/O failure is the door's fatal outcome (surfaced to
// the caller); the idle bound, the association's end, and a // the caller); the idle bound, the association's end, and a
// session error are the association's own ends — a clean return. // session error are the association's own ends, so the return
// is clean.
match exit { match exit {
UdpDoorExit::Io(err) => Err(err), UdpDoorExit::Io(err) => Err(err),
_ => Ok(()), _ => Ok(()),
@@ -992,7 +1004,7 @@ impl UdpFrontDoor {
} }
} }
/// The in-band frame send (the write half's shape — frame once with /// The in-band frame send (the write half's shape: frame once with
/// the wire codec, write the framed bytes to the channel write half). /// the wire codec, write the framed bytes to the channel write half).
async fn send_inband( async fn send_inband(
write: &mut tokio::io::WriteHalf<&mut AssociationStream>, write: &mut tokio::io::WriteHalf<&mut AssociationStream>,
@@ -1006,11 +1018,12 @@ async fn send_inband(
} }
/// The in-band frame read (the read half's shape): a fresh-looking /// The in-band frame read (the read half's shape): a fresh-looking
/// async read whose decoder state **persists across calls** — the /// async read whose decoder state **persists across calls**: the
/// bridge's [`InbandBridge`] owns the incremental `DatagramReader` /// bridge's [`InbandBridge`] owns the incremental `DatagramReader`
/// and the batched-datagram pending queue across calls (a datagram /// and the batched-datagram pending queue across calls (a datagram
/// split across channel chunks reassembles across reads; frames /// split across channel chunks reassembles across reads; frames
/// decoded beyond the one returned queue for later reads — the POC /// decoded beyond the one returned queue for later reads; this is
/// the POC
/// #2 reader-state finding, the same shape /// #2 reader-state finding, the same shape
/// `AssociateSession::recv_datagram` owns). /// `AssociateSession::recv_datagram` owns).
struct InbandBridge { struct InbandBridge {
@@ -1027,7 +1040,7 @@ impl InbandBridge {
} }
/// The next in-band datagram: the pending queue first (a chunk /// The next in-band datagram: the pending queue first (a chunk
/// may have batched several frames — drain in order), then an /// may have batched several frames, drained in order), then an
/// incremental decode off the read half. `Ok(None)` on channel /// incremental decode off the read half. `Ok(None)` on channel
/// EOF (the association's end); fail-loud mid-frame (never a /// EOF (the association's end); fail-loud mid-frame (never a
/// silent partial datagram). /// silent partial datagram).
@@ -1071,7 +1084,7 @@ impl InbandBridge {
} }
/// The channel→socket pump's decode call (the bridge the pump loop /// The channel→socket pump's decode call (the bridge the pump loop
/// drives — the decode state lives in the bridge, not per call). /// drives, so the decode state lives in the bridge, not per call).
async fn recv_inband<R>( async fn recv_inband<R>(
bridge: &mut InbandBridge, bridge: &mut InbandBridge,
read: &mut R, read: &mut R,
@@ -1083,7 +1096,8 @@ where
} }
/// Map a wire-decode error into the RFC vocabulary (the consumer /// Map a wire-decode error into the RFC vocabulary (the consumer
/// module's mapping, re-declared here — the private consumer helper is /// module's mapping, re-declared here, since the private consumer
/// helper is
/// not part of the public surface). /// not part of the public surface).
fn wire_error(err: WireError) -> SocksSessionError { fn wire_error(err: WireError) -> SocksSessionError {
match err { match err {
@@ -1095,7 +1109,7 @@ fn wire_error(err: WireError) -> SocksSessionError {
} }
} }
/// The association's framed stream type (the split halves' target — /// The association's framed stream type (the split halves' target,
/// the forked client type over the channel's `BiStream`). /// the forked client type over the channel's `BiStream`).
type AssociationStream = crate::rfc1928::client::Socks5Stream<alkcall::core::types::BiStream>; type AssociationStream = crate::rfc1928::client::Socks5Stream<alkcall::core::types::BiStream>;
@@ -1108,7 +1122,7 @@ mod tests {
/// Drive one pump-shaped decode call: feed `bytes` from the peer /// Drive one pump-shaped decode call: feed `bytes` from the peer
/// side, then decode (the recv parks until a frame completes or /// side, then decode (the recv parks until a frame completes or
/// the stream ends — the pump loop's shape). /// the stream ends, matching the pump loop's shape).
async fn feed_then_recv<R>( async fn feed_then_recv<R>(
bridge: &mut InbandBridge, bridge: &mut InbandBridge,
read: &mut R, read: &mut R,
@@ -1129,7 +1143,7 @@ mod tests {
async fn batched_frames_queue_not_drop() { async fn batched_frames_queue_not_drop() {
// Two frames batched into one feed: the first returns, the // Two frames batched into one feed: the first returns, the
// second queues and drains on the next call (the per-call // second queues and drains on the next call (the per-call
// reader dropped it — review 003 R-1; the pending queue is // reader dropped it, review 003 R-1; the pending queue is
// the state that persists across the call boundary). // the state that persists across the call boundary).
let (mut writer, reader) = tokio::io::duplex(1024); let (mut writer, reader) = tokio::io::duplex(1024);
let (mut channel_read, _channel_write) = tokio::io::split(reader); let (mut channel_read, _channel_write) = tokio::io::split(reader);
@@ -1153,7 +1167,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn eof_mid_frame_fails_loud_not_silent() { async fn eof_mid_frame_fails_loud_not_silent() {
// A frame in flight when the stream ends: the decode errors // A frame in flight when the stream ends: the decode errors
// loud (never a silent partial datagram) — the same shape // loud (never a silent partial datagram), matching the same
// shape
// AssociateSession::recv_datagram pins. // AssociateSession::recv_datagram pins.
let (mut writer, reader) = tokio::io::duplex(1024); let (mut writer, reader) = tokio::io::duplex(1024);
let (mut channel_read, _channel_write) = tokio::io::split(reader); let (mut channel_read, _channel_write) = tokio::io::split(reader);
@@ -1183,7 +1198,7 @@ mod tests {
async fn partial_frame_state_persists_across_calls() { async fn partial_frame_state_persists_across_calls() {
// One chunk carries frame 1 complete + the first bytes of // One chunk carries frame 1 complete + the first bytes of
// frame 2: call 1 returns frame 1 (frame 2's bytes stay in // frame 2: call 1 returns frame 1 (frame 2's bytes stay in
// the bridge's reader), call 2 — after the stream ends — // the bridge's reader), and call 2 (after the stream ends)
// fails loud on the surviving mid-frame state (a per-call // fails loud on the surviving mid-frame state (a per-call
// reader would report a clean Ok(None) EOF here). // reader would report a clean Ok(None) EOF here).
let (mut writer, reader) = tokio::io::duplex(1024); let (mut writer, reader) = tokio::io::duplex(1024);
@@ -1222,8 +1237,8 @@ mod refusal_and_wire_unit_tests {
use alkcall::protocol::wire::CallError; use alkcall::protocol::wire::CallError;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
/// A connected TcpStream pair (the loopback listener shape — /// A connected TcpStream pair (the loopback listener shape;
/// `TcpStream` has no `pair` constructor; the listener dial is /// `TcpStream` has no `pair` constructor, so the listener dial is
/// the portable way). /// the portable way).
async fn socket_pair() -> (tokio::net::TcpStream, tokio::net::TcpStream) { async fn socket_pair() -> (tokio::net::TcpStream, tokio::net::TcpStream) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0") let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -1238,7 +1253,7 @@ mod refusal_and_wire_unit_tests {
/// The BIND reply#1 wire table (review 003 N-8: the Domain arm was /// The BIND reply#1 wire table (review 003 N-8: the Domain arm was
/// hand-written wire code with no test): the IP form decodes /// hand-written wire code with no test): the IP form decodes
/// through the forked codec (`new_reply`'s own table covers it); /// through the forked codec (`new_reply`'s own table covers it);
/// the Domain form's bytes are pinned exactly — VER, REP, RSV, /// the Domain form's bytes are pinned exactly (VER, REP, RSV,
/// ATYP=domain, len, name, port. /// ATYP=domain, len, name, port.
#[test] #[test]
fn bind_reply1_wire_forms() { fn bind_reply1_wire_forms() {
@@ -1267,7 +1282,7 @@ mod refusal_and_wire_unit_tests {
} }
/// `write_refusal_reply`'s non-reply-code arm (review 003 N-8): an /// `write_refusal_reply`'s non-reply-code arm (review 003 N-8): an
/// open/transport-shaped session error stays a bare close — no /// open/transport-shaped session error stays a bare close, so no
/// bytes are written to the vanilla client. /// bytes are written to the vanilla client.
#[tokio::test] #[tokio::test]
async fn write_refusal_reply_skips_non_reply_code_shapes() { async fn write_refusal_reply_skips_non_reply_code_shapes() {
@@ -1278,8 +1293,8 @@ mod refusal_and_wire_unit_tests {
})); }));
write_refusal_reply(&mut server, &open_err).await; write_refusal_reply(&mut server, &open_err).await;
// Nothing written: the client's read finds no bytes (the bare // Nothing written: the client's read finds no bytes (the bare
// close is the door's caller's act — the helper itself is // close is the door's caller's act, since the helper itself
// silent). A short window proves no reply bytes exist. // is silent). A short window proves no reply bytes exist.
let mut probe = [0u8; 16]; let mut probe = [0u8; 16];
let n = tokio::time::timeout( let n = tokio::time::timeout(
std::time::Duration::from_millis(200), std::time::Duration::from_millis(200),
@@ -1323,7 +1338,8 @@ mod bridge_later_batch_tests {
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
/// A decode failure in a *later* batch frame (review 003 N-8's /// A decode failure in a *later* batch frame (review 003 N-8's
/// residual: the unit tests pinned the happy shapes — a garbage /// residual: the unit tests pinned the happy shapes, where a
/// garbage
/// frame following a good one in the same chunk surfaces the /// frame following a good one in the same chunk surfaces the
/// error, the good frame is NOT silently delivered first). /// error, the good frame is NOT silently delivered first).
#[tokio::test] #[tokio::test]
@@ -1333,7 +1349,7 @@ mod bridge_later_batch_tests {
let addr = TargetAddr::Ip("10.0.0.7:53".parse().expect("addr")); let addr = TargetAddr::Ip("10.0.0.7:53".parse().expect("addr"));
let mut wire = crate::wire::frame(&addr, b"good").unwrap().to_vec(); let mut wire = crate::wire::frame(&addr, b"good").unwrap().to_vec();
// A later frame whose length field promises more bytes than // A later frame whose length field promises more bytes than
// the chunk carries, then the stream ends — the reader's // the chunk carries, then the stream ends, so the reader's
// state machine fails loud on the truncated later frame. // state machine fails loud on the truncated later frame.
wire.extend_from_slice(&[0xff, 0xff]); wire.extend_from_slice(&[0xff, 0xff]);
wire.extend_from_slice(b"short"); wire.extend_from_slice(b"short");
+48 -47
View File
@@ -1,12 +1,13 @@
//! The three I/O-plane seam types (ADR-004) — the dial seam (CONNECT), //! The three I/O-plane seam types (ADR-004), each a function-shaped
//! the egress seam (UDP ASSOCIATE), the accept seam (TCP BIND) — as //! callback with its error vocabulary: the dial seam (CONNECT), the
//! function-shaped callbacks with their error vocabularies. These types //! egress seam (UDP ASSOCIATE), and the accept seam (TCP BIND). These
//! are the inversion point (AGENTS.md convention 7): the protocol layer //! types are the inversion point (AGENTS.md convention 7): the
//! protocol layer
//! never names a substrate type; the feature-gated providers //! never names a substrate type; the feature-gated providers
//! (`local`/`composed`, batch 2) and the test harness construct them. //! (`local`/`composed`, batch 2) and the test harness construct them.
//! //!
//! **Function, not trait** (ADR-004): [`DialFn`], [`EgressFactory`], //! **Function, not trait** (ADR-004): [`DialFn`], [`EgressFactory`],
//! and [`BindAcceptFactory`] are function-shaped callbacks — "produce //! and [`BindAcceptFactory`] are function-shaped callbacks: "produce
//! boxed halves for a resource" is a function, not a trait (the //! boxed halves for a resource" is a function, not a trait (the
//! alktunnels ADR-004 finding, extended to all three SOCKS5 seams by //! alktunnels ADR-004 finding, extended to all three SOCKS5 seams by
//! POC #1/#2/#5). [`Egress`] is the one trait: the per-association //! POC #1/#2/#5). [`Egress`] is the one trait: the per-association
@@ -19,29 +20,29 @@
//! - **Ownership rides the handle structurally** ([`TargetHandle`], //! - **Ownership rides the handle structurally** ([`TargetHandle`],
//! [`BindTargetHandle`]): the payloads own their halves; any //! [`BindTargetHandle`]): the payloads own their halves; any
//! keep-alive state a substrate needs (e.g. a composed variant's //! keep-alive state a substrate needs (e.g. a composed variant's
//! channel session) rides inside the halves — a provider-side owner //! channel session) rides inside the halves (a provider-side owner
//! wrapper — so dropping the handle drops everything it dialed or //! wrapper), so dropping the handle drops everything it dialed or
//! accepted. No `_` bindings of owning values: the //! accepted. No `_` bindings of owning values: the
//! teardown-at-birth discipline, compiler-enforced by the shape. //! teardown-at-birth discipline, compiler-enforced by the shape.
//! - **The egress speaks inner bytes**: [`Egress::send`] takes the //! - **The egress speaks inner bytes**: [`Egress::send`] takes the
//! payload only; [`Egress::next_reply`] returns `addr + data` — the //! payload only; [`Egress::next_reply`] returns `addr + data`; the
//! frame codec lives on the wire side, applied exactly once //! frame codec lives on the wire side, applied exactly once
//! ([`crate::wire`]). //! ([`crate::wire`]).
//! - **The readiness contract** ([`Egress::next_reply`]): `Pending` //! - **The readiness contract** ([`Egress::next_reply`]): `Pending`
//! when no reply is ready — an instantly-ready `Ok(None)` starves a //! when no reply is ready; an instantly-ready `Ok(None)` starves a
//! biased `select!` into livelock (POC #2's structural finding). //! biased `select!` into livelock (POC #2's structural finding).
//! - **The accept future owns its resources end-to-end** //! - **The accept future owns its resources end-to-end**
//! ([`BindAccept`]): the quiet wait's `select!` drop IS the //! ([`BindAccept`]): the quiet wait's `select!` drop IS the
//! client-gone teardown (ADR-004/ADR-007). //! client-gone teardown (ADR-004/ADR-007).
//! - **Refusal is RFC-faithful**: every error type maps to RFC 1928 //! - **Refusal is RFC-faithful**: every error type maps to RFC 1928
//! reply codes via `to_reply_error()`; a dial/egress/accept failure //! reply codes via `to_reply_error()`; a dial/egress/accept failure
//! is reply-code-shaped (never coerced to a channels-level error — //! is reply-code-shaped (never coerced to a channels-level error;
//! session-level refusal stays channels-level, target-level refusal //! session-level refusal stays channels-level, target-level refusal
//! is in-stream). //! is in-stream).
//! //!
//! The readiness-contract behavioral tests (an instant-`Ok(None)` //! The readiness-contract behavioral tests (an instant-`Ok(None)`
//! egress must not starve the datagram stage — including the //! egress must not starve the datagram stage, including the
//! zero-yield shape, the gate review's R-1, //! zero-yield shape (the gate review's R-1,
//! tasks/implementation/review-protocol-core.md) live in the producer //! tasks/implementation/review-protocol-core.md) live in the producer
//! module's test suite, next to the stage they pin. This module //! module's test suite, next to the stage they pin. This module
//! carries the error→reply-code mapping tables, the sync-lift factory //! carries the error→reply-code mapping tables, the sync-lift factory
@@ -58,7 +59,7 @@ use tokio::io::{AsyncRead, AsyncWrite};
pub use crate::rfc1928::util::target_addr::TargetAddr; pub use crate::rfc1928::util::target_addr::TargetAddr;
use crate::rfc1928::ReplyError; use crate::rfc1928::ReplyError;
/// The dialed CONNECT target — boxed halves, `+ Sync` per the /// The dialed CONNECT target: boxed halves, `+ Sync` per the
/// `ChannelPlan` payload bound (ADR-004). Ownership rides the handle /// `ChannelPlan` payload bound (ADR-004). Ownership rides the handle
/// structurally: the payloads own their connection halves; keep-alive /// structurally: the payloads own their connection halves; keep-alive
/// state a substrate needs must ride inside the halves (a provider-side /// state a substrate needs must ride inside the halves (a provider-side
@@ -78,16 +79,16 @@ impl std::fmt::Debug for TargetHandle {
/// The dial seam (ADR-004): the substrate dial function, injected at /// The dial seam (ADR-004): the substrate dial function, injected at
/// registration/assembly. Called once per CONNECT at the command-read /// registration/assembly. Called once per CONNECT at the command-read
/// interception point with the RFC 1928 target the client requested /// interception point with the RFC 1928 target the client requested
/// (domain form passes through unresolved — ADR-012; resolution is the /// (domain form passes through unresolved, per ADR-012; resolution is
/// provider's job). Per-target refusal is the returned /// the provider's job). Per-target refusal is the returned
/// [`SocksDialError`] (the producer replies with the mapped RFC code — /// [`SocksDialError`] (the producer replies with the mapped RFC code,
/// no phantom data plane); session-level refusal stays channels-level. /// so no phantom data plane); session-level refusal stays channels-level.
pub type DialFn = Arc< pub type DialFn = Arc<
dyn Fn(TargetAddr) -> BoxFuture<'static, Result<TargetHandle, SocksDialError>> + Send + Sync, dyn Fn(TargetAddr) -> BoxFuture<'static, Result<TargetHandle, SocksDialError>> + Send + Sync,
>; >;
/// A dial failure, mapped to RFC 1928 reply codes by /// A dial failure, mapped to RFC 1928 reply codes by
/// [`SocksDialError::to_reply_error`] (never a generic error — /// [`SocksDialError::to_reply_error`] (never a generic error;
/// AGENTS.md convention 2's faithful `ReplyError` mapping). /// AGENTS.md convention 2's faithful `ReplyError` mapping).
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum SocksDialError { pub enum SocksDialError {
@@ -109,7 +110,7 @@ impl SocksDialError {
/// `NetworkUnreachable` → `NetworkUnreachable` (0x03), /// `NetworkUnreachable` → `NetworkUnreachable` (0x03),
/// `HostUnreachable` → `HostUnreachable` (0x04), `NotAllowed` → /// `HostUnreachable` → `HostUnreachable` (0x04), `NotAllowed` →
/// `ConnectionNotAllowed` (0x02), `General` → `GeneralFailure` /// `ConnectionNotAllowed` (0x02), `General` → `GeneralFailure`
/// (0x01) — the POC #1 mapping, table-tested in this module. /// (0x01); the POC #1 mapping, table-tested in this module.
pub fn to_reply_error(&self) -> ReplyError { pub fn to_reply_error(&self) -> ReplyError {
match self { match self {
Self::Refused(_) => ReplyError::ConnectionRefused, Self::Refused(_) => ReplyError::ConnectionRefused,
@@ -122,9 +123,9 @@ impl SocksDialError {
} }
/// One association's egress failure. Mapped to RFC reply codes only at /// One association's egress failure. Mapped to RFC reply codes only at
/// association setup ([`EgressError::to_reply_error`] — an egress that /// association setup ([`EgressError::to_reply_error`]; an egress that
/// cannot start refuses the ASSOCIATE pre-reply); per-datagram failures /// cannot start refuses the ASSOCIATE pre-reply); per-datagram failures
/// are not per-datagram refusals — the producer stage ends the /// are not per-datagram refusals, so the producer stage ends the
/// association on an `Err` from the seam (the lossy-datagram posture). /// association on an `Err` from the seam (the lossy-datagram posture).
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum EgressError { pub enum EgressError {
@@ -140,7 +141,7 @@ impl EgressError {
/// The RFC 1928 reply code for the ASSOCIATE refusal: `Refused` → /// The RFC 1928 reply code for the ASSOCIATE refusal: `Refused` →
/// `ConnectionRefused` (0x05), `Unreachable` → /// `ConnectionRefused` (0x05), `Unreachable` →
/// `NetworkUnreachable` (0x03), `General` → `GeneralFailure` /// `NetworkUnreachable` (0x03), `General` → `GeneralFailure`
/// (0x01) — the POC #2 mapping, table-tested in this module. /// (0x01), the POC #2 mapping, table-tested in this module.
pub fn to_reply_error(&self) -> ReplyError { pub fn to_reply_error(&self) -> ReplyError {
match self { match self {
Self::Refused(_) => ReplyError::ConnectionRefused, Self::Refused(_) => ReplyError::ConnectionRefused,
@@ -150,26 +151,26 @@ impl EgressError {
} }
} }
/// The per-association datagram egress — the ASSOCIATE seam's returned /// The per-association datagram egress, the ASSOCIATE seam's returned
/// object and the module's one trait (ADR-004: the factory is /// object and the module's one trait (ADR-004: the factory is
/// function-shaped; the instance it returns is a trait object, /// function-shaped; the instance it returns is a trait object,
/// `Box<dyn Egress + Send>`, owned by the producer's datagram stage — /// `Box<dyn Egress + Send>`, owned by the producer's datagram stage;
/// stage exit drops it, so egress state never outlives the channel). /// stage exit drops it, so egress state never outlives the channel).
/// ///
/// The stage never sees which substrate backs the association /// The stage never sees which substrate backs the association
/// (convention 7 at the egress layer). /// (convention 7 at the egress layer).
pub trait Egress { pub trait Egress {
/// Egress one datagram to `addr`. Payload-only: the producer stage /// Egress one datagram to `addr`. Payload-only: the producer stage
/// decoded the wire frame and hands the bare datagram here — the /// decoded the wire frame and hands the bare datagram here, and the
/// egress never sees a length prefix (framing is applied exactly /// egress never sees a length prefix (framing is applied exactly
/// once, by the wire layer). A zero-length payload is a legal /// once, by the wire layer). A zero-length payload is a legal
/// empty datagram, not a signal of any kind (the F-2 invariant). /// empty datagram, not a signal of any kind (the F-2 invariant).
fn send(&mut self, addr: TargetAddr, payload: Bytes) -> BoxFuture<'_, Result<(), EgressError>>; fn send(&mut self, addr: TargetAddr, payload: Bytes) -> BoxFuture<'_, Result<(), EgressError>>;
/// Yield the next inbound datagram's *inner* bytes (`addr + data` /// Yield the next inbound datagram's *inner* bytes (`addr + data`,
/// — the RFC address encoding followed by the payload, exactly /// the RFC address encoding followed by the payload, exactly
/// [`TargetAddr::to_be_bytes()`] + data). The length prefix is the /// [`TargetAddr::to_be_bytes()`] + data). The length prefix is the
/// wire layer's, added by the producer's frame writer — an egress /// wire layer's, added by the producer's frame writer; an egress
/// that prefixes again is the double-framing bug class POC #2 /// that prefixes again is the double-framing bug class POC #2
/// caught. /// caught.
/// ///
@@ -181,14 +182,14 @@ pub trait Egress {
/// and a hot `Ok(None)` never yields to the channel branch (nor to /// and a hot `Ok(None)` never yields to the channel branch (nor to
/// the runtime's other tasks on a current-thread scheduler). /// the runtime's other tasks on a current-thread scheduler).
/// `Ok(None)` is tolerated as a spurious wakeup (the stage /// `Ok(None)` is tolerated as a spurious wakeup (the stage
/// re-parks), not as a readiness signal — implement the wait as a /// re-parks), not as a readiness signal; implement the wait as a
/// real async park. An `Err` ends the association (the stage /// real async park. An `Err` ends the association (the stage
/// returns; the egress drops with it). /// returns; the egress drops with it).
fn next_reply(&mut self) -> BoxFuture<'_, Result<Option<Bytes>, EgressError>>; fn next_reply(&mut self) -> BoxFuture<'_, Result<Option<Bytes>, EgressError>>;
} }
/// The egress seam (ADR-004): the per-association egress factory — the /// The egress seam (ADR-004): the per-association egress factory (the
/// [`DialFn`] analogue for the datagram stage, injected at /// [`DialFn`] analogue for the datagram stage), injected at
/// registration/assembly. Called once per UDP ASSOCIATE; the stage owns /// registration/assembly. Called once per UDP ASSOCIATE; the stage owns
/// the returned egress; stage exit drops it. /// the returned egress; stage exit drops it.
pub type EgressFactory = pub type EgressFactory =
@@ -208,16 +209,16 @@ where
}) })
} }
/// The one accepted inbound connection's handle — reply#2's BND.ADDR /// The one accepted inbound connection's handle, reply#2's BND.ADDR
/// carrier plus the data-plane halves. Ownership rides the handle /// carrier plus the data-plane halves. Ownership rides the handle
/// structurally: the payloads own their accepted halves; keep-alive /// structurally: the payloads own their accepted halves; keep-alive
/// state a substrate needs (e.g. a composed variant's channel session) /// state a substrate needs (e.g. a composed variant's channel session)
/// rides inside the halves (a provider-side owner wrapper), so the /// rides inside the halves (a provider-side owner wrapper), so the
/// compiler enforces the teardown contract — no `_` bindings of owning /// compiler enforces the teardown contract, with no `_` bindings of
/// values (the third teardown-at-birth witness, ADR-004). /// owning values (the third teardown-at-birth witness, ADR-004).
pub struct BindTargetHandle { pub struct BindTargetHandle {
/// The accepted connection's peer address (reply#2's BND.ADDR — /// The accepted connection's peer address (reply#2's BND.ADDR, the
/// the RFC's "client IP and port of the incoming connection"). /// RFC's "client IP and port of the incoming connection").
pub peer: TargetAddr, pub peer: TargetAddr,
pub read: Box<dyn AsyncRead + Send + Sync + Unpin>, pub read: Box<dyn AsyncRead + Send + Sync + Unpin>,
pub write: Box<dyn AsyncWrite + Send + Sync + Unpin>, pub write: Box<dyn AsyncWrite + Send + Sync + Unpin>,
@@ -232,14 +233,14 @@ impl std::fmt::Debug for BindTargetHandle {
} }
/// One BIND session's accept source. `bind_addr` is the address /// One BIND session's accept source. `bind_addr` is the address
/// reply#1 advertises (BND.ADDR — the real bound listener on the local /// reply#1 advertises (BND.ADDR: the real bound listener on the local
/// variant, the advertised address on the composed one); the accept /// variant, the advertised address on the composed one); the accept
/// future is awaited exactly once, in the producer's quiet-wait phase. /// future is awaited exactly once, in the producer's quiet-wait phase.
/// ///
/// **The accept future owns its resources end-to-end** (ADR-004/ /// **The accept future owns its resources end-to-end** (ADR-004/
/// ADR-007): whatever the provider opened (the listener, the tunnel) /// ADR-007): whatever the provider opened (the listener, the tunnel)
/// lives inside the future, so the quiet wait's `select!` drop of that /// lives inside the future, so the quiet wait's `select!` drop of that
/// future IS the client-gone teardown — the listener closes when the /// future IS the client-gone teardown; the listener closes when the
/// client vanishes, with no extra teardown path. /// client vanishes, with no extra teardown path.
pub struct BindAccept { pub struct BindAccept {
bind_addr: SocketAddr, bind_addr: SocketAddr,
@@ -248,7 +249,7 @@ pub struct BindAccept {
impl BindAccept { impl BindAccept {
/// Build the session's accept source: `bind_addr` for reply#1, the /// Build the session's accept source: `bind_addr` for reply#1, the
/// one-inbound-accept future (owned end-to-end — see the type /// one-inbound-accept future (owned end-to-end; see the type
/// docs). /// docs).
pub fn new( pub fn new(
bind_addr: SocketAddr, bind_addr: SocketAddr,
@@ -265,7 +266,7 @@ impl BindAccept {
self.bind_addr self.bind_addr
} }
/// Await the one inbound accept (the quiet wait's accept branch — /// Await the one inbound accept (the quiet wait's accept branch,
/// exactly once; the future is consumed). /// exactly once; the future is consumed).
pub async fn accept(self) -> Result<BindTargetHandle, BindAcceptError> { pub async fn accept(self) -> Result<BindTargetHandle, BindAcceptError> {
self.accept.await self.accept.await
@@ -281,7 +282,7 @@ impl std::fmt::Debug for BindAccept {
} }
/// Accept-stage failures (post-reply#1): the producer maps them to RFC /// Accept-stage failures (post-reply#1): the producer maps them to RFC
/// reply codes for reply#2 — no data plane on refusal. /// reply codes for reply#2, and no data plane exists on refusal.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum BindAcceptError { pub enum BindAcceptError {
#[error("accept refused: {0}")] #[error("accept refused: {0}")]
@@ -296,7 +297,7 @@ impl BindAcceptError {
/// The RFC 1928 reply code for reply#2 on this refusal: `Refused` → /// The RFC 1928 reply code for reply#2 on this refusal: `Refused` →
/// `ConnectionRefused` (0x05), `NotAllowed` → /// `ConnectionRefused` (0x05), `NotAllowed` →
/// `ConnectionNotAllowed` (0x02), `General` → `GeneralFailure` /// `ConnectionNotAllowed` (0x02), `General` → `GeneralFailure`
/// (0x01) — the POC #5 mapping, table-tested in this module. /// (0x01), the POC #5 mapping, table-tested in this module.
pub fn to_reply_error(&self) -> ReplyError { pub fn to_reply_error(&self) -> ReplyError {
match self { match self {
Self::Refused(_) => ReplyError::ConnectionRefused, Self::Refused(_) => ReplyError::ConnectionRefused,
@@ -307,7 +308,7 @@ impl BindAcceptError {
} }
/// Factory failures (pre-reply#1): the producer maps them to RFC reply /// Factory failures (pre-reply#1): the producer maps them to RFC reply
/// codes for reply#1 — no phantom listener. /// codes for reply#1, with no phantom listener.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum BindAcceptFactoryError { pub enum BindAcceptFactoryError {
#[error("bind refused: {0}")] #[error("bind refused: {0}")]
@@ -324,8 +325,8 @@ impl BindAcceptFactoryError {
/// The RFC 1928 reply code for reply#1 on this refusal: `Refused` → /// The RFC 1928 reply code for reply#1 on this refusal: `Refused` →
/// `ConnectionRefused` (0x05), `NotAllowed` → /// `ConnectionRefused` (0x05), `NotAllowed` →
/// `ConnectionNotAllowed` (0x02), `Shortage`/`General` → /// `ConnectionNotAllowed` (0x02), `Shortage`/`General` →
/// `GeneralFailure` (0x01 — the RFC's catch-all; a resource /// `GeneralFailure` (0x01; the RFC's catch-all, since a resource
/// shortage has no dedicated code) — the POC #5 mapping, /// shortage has no dedicated code), the POC #5 mapping,
/// table-tested in this module. /// table-tested in this module.
pub fn to_reply_error(&self) -> ReplyError { pub fn to_reply_error(&self) -> ReplyError {
match self { match self {
@@ -337,12 +338,12 @@ impl BindAcceptFactoryError {
} }
} }
/// The accept seam (ADR-004): the per-BIND accept factory — the /// The accept seam (ADR-004): the per-BIND accept factory, the
/// [`DialFn`]'s mirror, injected at registration/assembly. Called once /// [`DialFn`]'s mirror, injected at registration/assembly. Called once
/// per TCP BIND with the requested target. The requested port is the /// per TCP BIND with the requested target. The requested port is the
/// only field a real bind can honor (domains are meaningless as bind /// only field a real bind can honor (domains are meaningless as bind
/// addresses); the address policy is the factory's (ADR-006/ADR-012: /// addresses); the address policy is the factory's (ADR-006/ADR-012:
/// the callbacks refuse — RFC reply codes). /// the callbacks refuse, with RFC reply codes).
/// ///
/// Failure mapping is two-stage (ADR-007): factory failure /// Failure mapping is two-stage (ADR-007): factory failure
/// ([`BindAcceptFactoryError`]) → reply#1 with the mapped code; /// ([`BindAcceptFactoryError`]) → reply#1 with the mapped code;
+12 -12
View File
@@ -5,10 +5,10 @@
//! [`SocksServerError`], [`ReplyError`]). //! [`SocksServerError`], [`ReplyError`]).
//! //!
//! Open errors are channels-level: a failed open never yields a //! Open errors are channels-level: a failed open never yields a
//! phantom channel (the no-phantom-channel property, ADR-049 — the //! phantom channel (the no-phantom-channel property, ADR-049: the
//! session never exists opener-side). Target-level refusal happens //! session never exists opener-side). Target-level refusal happens
//! later, in-stream, as an RFC reply code surfaced through //! later, in-stream, as an RFC reply code surfaced through
//! [`SocksSessionError::Rfc`] (consumer.md §Error surface) — never as //! [`SocksSessionError::Rfc`] (consumer.md §Error surface), never as
//! an open failure. //! an open failure.
use alkcall::channels::client::ChannelOpenError; use alkcall::channels::client::ChannelOpenError;
@@ -17,7 +17,7 @@ pub use crate::rfc1928::server::SocksServerError;
pub use crate::rfc1928::{ReplyError, SocksError}; pub use crate::rfc1928::{ReplyError, SocksError};
/// The typed open-failure vocabulary (alkcall ADR-049 §4): a failed /// The typed open-failure vocabulary (alkcall ADR-049 §4): a failed
/// SOCKS5 channel open. A failure never yields a session — no phantom /// SOCKS5 channel open. A failure never yields a session: no phantom
/// channel (the channel is torn down accept-side and no `channel_id` /// channel (the channel is torn down accept-side and no `channel_id`
/// was ever returned). /// was ever returned).
/// ///
@@ -33,7 +33,7 @@ pub use crate::rfc1928::{ReplyError, SocksError};
/// [`ChannelOpenError`]. /// [`ChannelOpenError`].
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum SocksOpenError { pub enum SocksOpenError {
/// The open op failed — the typed [`ChannelOpenError`] carries the /// The open op failed; the typed [`ChannelOpenError`] carries the
/// wire [`alkcall::protocol::wire::CallError`] verbatim /// wire [`alkcall::protocol::wire::CallError`] verbatim
/// (`channel:open_failed` + `details.reason` per ADR-049 §3 when /// (`channel:open_failed` + `details.reason` per ADR-049 §3 when
/// the establisher refused; `FORBIDDEN`, `INVALID_INPUT`, /// the establisher refused; `FORBIDDEN`, `INVALID_INPUT`,
@@ -52,7 +52,7 @@ impl SocksOpenError {
/// The typed establishment-failure reason of a /// The typed establishment-failure reason of a
/// `channel:open_failed` error (`dial_failed` / `unknown_resource` /// `channel:open_failed` error (`dial_failed` / `unknown_resource`
/// / `resource_shortage` / `handler_error` / `timeout` — alkcall /// / `resource_shortage` / `handler_error` / `timeout`; alkcall
/// ADR-049 §3's vocabulary, parsed). `None` for any other failure /// ADR-049 §3's vocabulary, parsed). `None` for any other failure
/// shape: the registry's `FORBIDDEN`/`INVALID_INPUT` classes, /// shape: the registry's `FORBIDDEN`/`INVALID_INPUT` classes,
/// `channel:too_many_channels`, or local-only failures /// `channel:too_many_channels`, or local-only failures
@@ -65,7 +65,7 @@ impl SocksOpenError {
/// The typed establishment-reason vocabulary (alkcall ADR-049 §3's /// The typed establishment-reason vocabulary (alkcall ADR-049 §3's
/// reason codes, parsed from the wire string). One-way once consumers /// reason codes, parsed from the wire string). One-way once consumers
/// branch on it (ADR-049's door-type note) — the string codes are the /// branch on it (ADR-049's door-type note): the string codes are the
/// wire truth; this enum is the typed view. /// wire truth; this enum is the typed view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SocksEstablishmentReason { pub enum SocksEstablishmentReason {
@@ -83,7 +83,7 @@ pub enum SocksEstablishmentReason {
impl SocksEstablishmentReason { impl SocksEstablishmentReason {
/// Parse the wire `details.reason` string (ADR-049 §3's /// Parse the wire `details.reason` string (ADR-049 §3's
/// vocabulary). Unknown strings are `None` — a *new* reason code /// vocabulary). Unknown strings are `None`: a *new* reason code
/// from a newer producer must not silently alias an old variant /// from a newer producer must not silently alias an old variant
/// (the vocabulary is one-way; additive codes need a migration). /// (the vocabulary is one-way; additive codes need a migration).
pub fn parse(reason: &str) -> Option<Self> { pub fn parse(reason: &str) -> Option<Self> {
@@ -117,8 +117,8 @@ impl std::fmt::Display for SocksEstablishmentReason {
/// The consumer session's error surface (consumer.md §Error surface): /// The consumer session's error surface (consumer.md §Error surface):
/// open failures are channels-level and typed /// open failures are channels-level and typed
/// ([`SocksOpenError`]); in-stream RFC failures (a refused target — /// ([`SocksOpenError`]); in-stream RFC failures (the refused-target
/// the reply code) are [`SocksSessionError::Rfc`]; the data-plane /// reply code) are [`SocksSessionError::Rfc`]; the data-plane
/// codec failures are [`SocksSessionError::Rfc`]'s inner /// codec failures are [`SocksSessionError::Rfc`]'s inner
/// [`SocksError`] and [`SocksSessionError::Io`] is the plain I/O /// [`SocksError`] and [`SocksSessionError::Io`] is the plain I/O
/// failure. /// failure.
@@ -131,7 +131,7 @@ pub enum SocksSessionError {
/// The channel open failed (the session never came to exist). /// The channel open failed (the session never came to exist).
#[error("socks5 open failed: {0}")] #[error("socks5 open failed: {0}")]
Open(#[from] SocksOpenError), Open(#[from] SocksOpenError),
/// The in-stream RFC 1928 conversation failed — a refused target /// The in-stream RFC 1928 conversation failed: a refused target
/// (the reply code, e.g. `ConnectionNotAllowed` 0x02 for a BIND /// (the reply code, e.g. `ConnectionNotAllowed` 0x02 for a BIND
/// without `socks5:bind`), an auth rejection, a malformed /// without `socks5:bind`), an auth rejection, a malformed
/// negotiation, or the UDP-frame codec. The RFC-faithful surface: /// negotiation, or the UDP-frame codec. The RFC-faithful surface:
@@ -180,7 +180,7 @@ mod tests {
#[test] #[test]
fn open_error_non_establishment_shapes_are_none() { fn open_error_non_establishment_shapes_are_none() {
// The registry's own gate classes run before the establisher — // The registry's own gate classes run before the establisher;
// they are NOT establishment reasons (ADR-049 §3). // they are NOT establishment reasons (ADR-049 §3).
for code in ["FORBIDDEN", "INVALID_INPUT", "channel:too_many_channels"] { for code in ["FORBIDDEN", "INVALID_INPUT", "channel:too_many_channels"] {
let err = SocksOpenError::Open(ChannelOpenError::CallFailed { let err = SocksOpenError::Open(ChannelOpenError::CallFailed {
@@ -215,7 +215,7 @@ mod tests {
// Open plane: a refused session never yields a phantom channel. // Open plane: a refused session never yields a phantom channel.
let open = SocksSessionError::from(open_failed_with("dial_failed")); let open = SocksSessionError::from(open_failed_with("dial_failed"));
assert!(matches!(open, SocksSessionError::Open(_))); assert!(matches!(open, SocksSessionError::Open(_)));
// In-stream RFC refusal — the reply code rides the session type // In-stream RFC refusal: the reply code rides the session type
// (consumer.md §Error surface), never an open failure. // (consumer.md §Error surface), never an open failure.
let refused = let refused =
SocksSessionError::from(SocksError::ReplyError(ReplyError::ConnectionNotAllowed)); SocksSessionError::from(SocksError::ReplyError(ReplyError::ConnectionNotAllowed));
+15 -15
View File
@@ -1,4 +1,4 @@
//! alksocks — SOCKS5 (RFC 1928) as a produced resource on alkcall //! alksocks: SOCKS5 (RFC 1928) as a produced resource on alkcall
//! channels. //! channels.
//! //!
//! A producer/consumer protocol crate (the alktty/alktunnels shape): //! A producer/consumer protocol crate (the alktty/alktunnels shape):
@@ -6,7 +6,7 @@
//! drives the RFC 1928 conversation in-stream over the channel's //! drives the RFC 1928 conversation in-stream over the channel's
//! `BiStream`; the consumer half opens SOCKS5 channels and speaks the //! `BiStream`; the consumer half opens SOCKS5 channels and speaks the
//! protocol inside them. The defining posture: the protocol never //! protocol inside them. The defining posture: the protocol never
//! binds a port — kernel binds exist only as explicit, feature-gated //! binds a port; kernel binds exist only as explicit, feature-gated
//! assembly shapes (`local`), never in the protocol layer. //! assembly shapes (`local`), never in the protocol layer.
//! //!
//! ## The landed surface (the protocol core) //! ## The landed surface (the protocol core)
@@ -14,18 +14,18 @@
//! - **The wire vocabulary and the explicit typestate** live in //! - **The wire vocabulary and the explicit typestate** live in
//! [`rfc1928`]: a targeted fork of fast-socks5 v1.0.0's `T`-generic //! [`rfc1928`]: a targeted fork of fast-socks5 v1.0.0's `T`-generic
//! subset (MIT, third-party upstream) carrying two deliberate //! subset (MIT, third-party upstream) carrying two deliberate
//! changes (BIND support, `new_reply` publication) — see //! changes (BIND support, `new_reply` publication); see
//! `docs/architecture/rfc1928.md`, ADR-013, and ADR-011. //! `docs/architecture/rfc1928.md`, ADR-013, and ADR-011.
//! - **The wire codec** ([`wire`]): the ASSOCIATE datagram frame //! - **The wire codec** ([`wire`]): the ASSOCIATE datagram frame
//! (`[len: u16 BE][addr + data]`, ADR-003) — framing is applied //! (`[len: u16 BE][addr + data]`, ADR-003): framing is applied
//! exactly once, by this layer; the egress speaks inner bytes. //! exactly once, by this layer; the egress speaks inner bytes.
//! - **The wire-stable open-op surface** ([`params`]): the //! - **The wire-stable open-op surface** ([`params`]): the
//! `channels/socks5/sub` op id, the `alk/socks5` ALPN, the //! `channels/socks5/sub` op id, the `alk/socks5` ALPN, the
//! trivial `{}` params (ADR-001), and the two scope constants //! trivial `{}` params (ADR-001), and the two scope constants
//! (`socks5:open`, `socks5:bind` — two grants, never implied, //! (`socks5:open`, `socks5:bind`; two grants, never implied,
//! ADR-006). The typed open-error vocabulary lives in [`error`]. //! ADR-006). The typed open-error vocabulary lives in [`error`].
//! - **The three seams** ([`egress`]): `DialFn` / `EgressFactory` / //! - **The three seams** ([`egress`]): `DialFn` / `EgressFactory` /
//! `BindAcceptFactory` (ADR-004) — functions, not traits, at the //! `BindAcceptFactory` (ADR-004): functions, not traits, at the
//! three I/O planes; ownership rides the handle structurally; the //! three I/O planes; ownership rides the handle structurally; the
//! egress readiness contract (`next_reply` `Pending` when nothing //! egress readiness contract (`next_reply` `Pending` when nothing
//! is ready) is load-bearing API. //! is ready) is load-bearing API.
@@ -34,25 +34,25 @@
//! ASSOCIATE datagram stage / BIND phase model), the scope gates //! ASSOCIATE datagram stage / BIND phase model), the scope gates
//! (the open gate `socks5:open` at the registry; the command gate //! (the open gate `socks5:open` at the registry; the command gate
//! `socks5:bind` at BIND dispatch), and //! `socks5:bind` at BIND dispatch), and
//! [`register_socks5_openable`] — substrate-agnostic over the //! [`register_socks5_openable`], substrate-agnostic over the
//! seams; never a bind in the protocol layer. //! seams; never a bind in the protocol layer.
//! - **The consumer half** ([`consumer`]): the typed channels-native //! - **The consumer half** ([`consumer`]): the typed channels-native
//! sessions — [`Socks5ChannelSession`] (CONNECT), //! sessions: [`Socks5ChannelSession`] (CONNECT),
//! [`AssociateSession`] (the framed datagram stage), //! [`AssociateSession`] (the framed datagram stage),
//! [`BindSession`] (the reply#1/reply#2 reader) — each owning its //! [`BindSession`] (the reply#1/reply#2 reader), each owning its
//! channel's teardown; no `Clone`. The cred-bearing front door's //! channel's teardown; no `Clone`. The cred-bearing front door's
//! re-origination rides the token-carrying surface (`TokenClient` //! re-origination rides the token-carrying surface (`TokenClient`
//! + the `*_with_token` constructor twins). //! + the `*_with_token` constructor twins).
//! //!
//! The assembly layer (feature-gated, ADR-008/009): `local` (the //! The assembly layer (feature-gated, ADR-008/009): `local` (the
//! socket-backed providers — `local::tcp_dial` / `local::udp_egress` //! socket-backed providers, `local::tcp_dial` / `local::udp_egress`
//! / `local::bind_accept` — plus the producer-side //! / `local::bind_accept`, plus the producer-side
//! `local::ProducerDoor`) and `door` (the consumer's front doors — //! `local::ProducerDoor`) and `door` (the consumer's front doors: the
//! the NoAuth raw-pipe + RFC 1929 cred-bearing `FrontDoor`, the //! NoAuth raw-pipe + RFC 1929 cred-bearing `FrontDoor`, the
//! `UdpFrontDoor` with the RFC codec at the door boundary only, //! `UdpFrontDoor` with the RFC codec at the door boundary only,
//! ADR-005/003/014) ride `local`; the composed providers //! ADR-005/003/014) ride `local`; the composed providers
//! (`composed::direct_egress` — the alktunnels direct-op egress, //! (`composed::direct_egress`, the alktunnels direct-op egress;
//! `composed::bindfirst_accept` — the bind-first accept, //! `composed::bindfirst_accept`, the bind-first accept;
//! ADR-007/008) ride `composed`. The default crate is protocol-only //! ADR-007/008) ride `composed`. The default crate is protocol-only
//! and wasm-clean by construction. //! and wasm-clean by construction.
//! //!
+21 -21
View File
@@ -1,10 +1,10 @@
//! The `local` feature's socket-backed seam providers (ADR-008's local //! The `local` feature's socket-backed seam providers (ADR-008's local
//! variants, ADR-009's feature gate): the three seams' faithful-RFC //! variants, ADR-009's feature gate): the three seams' faithful-RFC
//! assembly-layer constructors — [`tcp_dial`] (CONNECT), [`udp_egress`] //! assembly-layer constructors: [`tcp_dial`] (CONNECT), [`udp_egress`]
//! (UDP ASSOCIATE), [`bind_accept`] (TCP BIND). These are convenience //! (UDP ASSOCIATE), [`bind_accept`] (TCP BIND). These are convenience
//! constructors over the landed seam types ([`crate::egress`]); the //! constructors over the landed seam types ([`crate::egress`]); the
//! protocol layer never sees them, and **the providers ARE the explicit //! protocol layer never sees them, and **the providers ARE the explicit
//! bind decisions** — the never-binds posture (convention 9) holds //! bind decisions**; the never-binds posture (convention 9) holds
//! because the protocol layer never references this module: every bind //! because the protocol layer never references this module: every bind
//! below (the dial's connect, the egress socket's `127.0.0.1:0`, the //! below (the dial's connect, the egress socket's `127.0.0.1:0`, the
//! BIND listener) is the caller's explicit choice to enable this //! BIND listener) is the caller's explicit choice to enable this
@@ -13,24 +13,24 @@
//! The seam contracts each constructor honors (the landed types' //! The seam contracts each constructor honors (the landed types'
//! normative doc comments, `crate::egress`): //! normative doc comments, `crate::egress`):
//! //!
//! - **Ownership rides the handle structurally** — the payloads own //! - **Ownership rides the handle structurally**: the payloads own
//! their socket halves; dropping the handle drops the connection. //! their socket halves; dropping the handle drops the connection.
//! - **The egress speaks inner bytes** — [`Egress::send`] takes the //! - **The egress speaks inner bytes**: [`Egress::send`] takes the
//! payload only; [`Egress::next_reply`] returns //! payload only; [`Egress::next_reply`] returns
//! `TargetAddr::to_be_bytes() + data` (the *sender's* address, the //! `TargetAddr::to_be_bytes() + data` (the *sender's* address, the
//! RFC's per-datagram source semantics); framing-once — the length //! RFC's per-datagram source semantics); framing-once, so the length
//! prefix is the wire layer's ([`crate::wire`]), added exactly once //! prefix is the wire layer's ([`crate::wire`]), added exactly once
//! downstream. //! downstream.
//! - **The readiness contract** ([`Egress::next_reply`]) — parks on //! - **The readiness contract** ([`Egress::next_reply`]): parks on
//! `readable()` (a real async wait on the socket's receive //! `readable()` (a real async wait on the socket's receive
//! readiness), then `try_recv_from`; `WouldBlock` → the //! readiness), then `try_recv_from`; `WouldBlock` → the
//! spurious-wakeup `Ok(None)` shape; never a hot `Ok(None)`. //! spurious-wakeup `Ok(None)` shape; never a hot `Ok(None)`.
//! - **The accept future owns its resources end-to-end** — the //! - **The accept future owns its resources end-to-end**: the
//! listener lives inside the accept future; the quiet wait's drop IS //! listener lives inside the accept future; the quiet wait's drop IS
//! the client-gone teardown. //! the client-gone teardown.
//! //!
//! Resolution is the provider's job (ADR-012): domain targets/destinations //! Resolution is the provider's job (ADR-012): domain targets/destinations
//! resolve here, at the seam — the ssh `-D` semantic, via the //! resolve here, at the seam (the ssh `-D` semantic), via the
//! non-blocking [`tokio::net::lookup_host`] resolver. //! non-blocking [`tokio::net::lookup_host`] resolver.
use std::io::ErrorKind; use std::io::ErrorKind;
@@ -49,9 +49,9 @@ use crate::{
}; };
/// Resolve a [`TargetAddr`] to a `SocketAddr` (egress-side DNS; the /// Resolve a [`TargetAddr`] to a `SocketAddr` (egress-side DNS; the
/// ssh `-D` semantic — ADR-012: resolution is the provider's job). IP /// ssh `-D` semantic; ADR-012: resolution is the provider's job). IP
/// targets resolve directly; domain targets resolve via /// targets resolve directly; domain targets resolve via
/// [`tokio::net::lookup_host`] (the non-blocking resolver — review 003 /// [`tokio::net::lookup_host`] (the non-blocking resolver; review 003
/// S-1's blocking-`to_socket_addrs` residue retired; the first address /// S-1's blocking-`to_socket_addrs` residue retired; the first address
/// wins, upstream parity). /// wins, upstream parity).
pub(crate) async fn resolve_target(addr: &TargetAddr) -> Result<SocketAddr, EgressError> { pub(crate) async fn resolve_target(addr: &TargetAddr) -> Result<SocketAddr, EgressError> {
@@ -71,7 +71,7 @@ pub(crate) async fn resolve_target(addr: &TargetAddr) -> Result<SocketAddr, Egre
} }
/// Map an io error kind to the dial vocabulary (faithful-RFC mapping; /// Map an io error kind to the dial vocabulary (faithful-RFC mapping;
/// `NotAllowed` is never synthesized by this provider — it is a /// `NotAllowed` is never synthesized by this provider, as it is a
/// ruleset concept, not an io one). /// ruleset concept, not an io one).
pub(crate) fn dial_error_of(what: &str, err: &std::io::Error) -> SocksDialError { pub(crate) fn dial_error_of(what: &str, err: &std::io::Error) -> SocksDialError {
match err.kind() { match err.kind() {
@@ -89,7 +89,7 @@ pub(crate) fn dial_error_of(what: &str, err: &std::io::Error) -> SocksDialError
/// The CONNECT dial seam's local-socket variant (ADR-008): a real /// The CONNECT dial seam's local-socket variant (ADR-008): a real
/// `TcpStream` per CONNECT target; domain targets resolve here (the /// `TcpStream` per CONNECT target; domain targets resolve here (the
/// ssh `-D` semantic). The connected stream splits into the /// ssh `-D` semantic). The connected stream splits into the
/// [`TargetHandle`] halves (`TcpStream::into_split` — the halves are /// [`TargetHandle`] halves (`TcpStream::into_split`; the halves are
/// `Send + Sync + Unpin`, boxed per the seam's shape). Dial failures /// `Send + Sync + Unpin`, boxed per the seam's shape). Dial failures
/// map to [`SocksDialError`] faithfully ([`dial_error_of`]); /// map to [`SocksDialError`] faithfully ([`dial_error_of`]);
/// `NotAllowed` is never synthesized by this provider. /// `NotAllowed` is never synthesized by this provider.
@@ -136,12 +136,12 @@ async fn local_tcp_dial(target: TargetAddr) -> Result<TargetHandle, SocksDialErr
/// ///
/// **The readiness contract is load-bearing**: [`LocalEgress`]'s /// **The readiness contract is load-bearing**: [`LocalEgress`]'s
/// `next_reply` parks on `sock.readable().await` (a real async park on /// `next_reply` parks on `sock.readable().await` (a real async park on
/// receive readiness — `writable()` is always ready for UDP and would /// receive readiness, since `writable()` is always ready for UDP and would
/// spin), then `try_recv_from` (`WouldBlock` → the spurious-wakeup /// spin), then `try_recv_from` (`WouldBlock` → the spurious-wakeup
/// `Ok(None)` shape; a datagram arriving makes the socket readable). /// `Ok(None)` shape; a datagram arriving makes the socket readable).
/// Replies carry *inner* bytes (`TargetAddr::to_be_bytes() + data` — /// Replies carry *inner* bytes (`TargetAddr::to_be_bytes() + data`,
/// the sender's address per the RFC's per-datagram source semantics; /// the sender's address per the RFC's per-datagram source semantics;
/// framing-once — the length prefix is the wire layer's). `send` takes /// framing-once: the length prefix is the wire layer's). `send` takes
/// the payload only. /// the payload only.
pub fn udp_egress() -> EgressFactory { pub fn udp_egress() -> EgressFactory {
Arc::new(|| { Arc::new(|| {
@@ -161,7 +161,7 @@ pub struct LocalEgress {
impl LocalEgress { impl LocalEgress {
/// Bind the association's socket (the explicit bind decision: /// Bind the association's socket (the explicit bind decision:
/// loopback ephemeral — a wildcard bind is the caller's variant /// loopback ephemeral; a wildcard bind is the caller's variant
/// to add, never this constructor's default). /// to add, never this constructor's default).
pub async fn bind() -> Result<Self, EgressError> { pub async fn bind() -> Result<Self, EgressError> {
let sock = UdpSocket::bind("127.0.0.1:0") let sock = UdpSocket::bind("127.0.0.1:0")
@@ -190,7 +190,7 @@ impl Egress for LocalEgress {
Box::pin(async move { Box::pin(async move {
// A real async wait on the socket's receive readiness (a // A real async wait on the socket's receive readiness (a
// datagram arriving makes it readable): the stage's // datagram arriving makes it readable): the stage's
// select! parks here with the channel read — no busy-loop, // select! parks here with the channel read, so no busy-loop,
// no lost replies. // no lost replies.
sock.readable() sock.readable()
.await .await
@@ -198,7 +198,7 @@ impl Egress for LocalEgress {
let mut scratch = vec![0u8; 65535]; let mut scratch = vec![0u8; 65535];
match sock.try_recv_from(&mut scratch) { match sock.try_recv_from(&mut scratch) {
Ok((n, from)) => { Ok((n, from)) => {
// The INNER bytes (`addr + data` — the RFC's // The INNER bytes (`addr + data`, the RFC's
// per-datagram source semantics): the length // per-datagram source semantics): the length
// prefix is the wire layer's, applied exactly // prefix is the wire layer's, applied exactly
// once downstream. // once downstream.
@@ -209,7 +209,7 @@ impl Egress for LocalEgress {
Ok(Some(Bytes::from(inner))) Ok(Some(Bytes::from(inner)))
} }
// A spurious wakeup (the readiness contract's // A spurious wakeup (the readiness contract's
// tolerated shape — the stage re-parks; this happens // tolerated shape: the stage re-parks; this happens
// async, never a hot loop). // async, never a hot loop).
Err(e) if e.kind() == ErrorKind::WouldBlock => Ok(None), Err(e) if e.kind() == ErrorKind::WouldBlock => Ok(None),
Err(e) => Err(EgressError::General(format!("udp try_recv: {e}"))), Err(e) => Err(EgressError::General(format!("udp try_recv: {e}"))),
@@ -223,7 +223,7 @@ impl Egress for LocalEgress {
/// session. The requested target's port is honored when non-zero (FTP /// session. The requested target's port is honored when non-zero (FTP
/// active mode); port 0 binds an ephemeral port; `local_addr()` is /// active mode); port 0 binds an ephemeral port; `local_addr()` is
/// reply#1's `bind_addr`. The accept future owns the listener /// reply#1's `bind_addr`. The accept future owns the listener
/// end-to-end (the quiet wait's drop IS the client-gone teardown — the /// end-to-end (the quiet wait's drop IS the client-gone teardown, as
/// listener lives inside the future, never in a separate binding); /// listener lives inside the future, never in a separate binding);
/// `accept()`'s peer is reply#2's [`BindTargetHandle::peer`] /// `accept()`'s peer is reply#2's [`BindTargetHandle::peer`]
/// (byte-exact). Factory failures map to [`BindAcceptFactoryError`] /// (byte-exact). Factory failures map to [`BindAcceptFactoryError`]
@@ -231,7 +231,7 @@ impl Egress for LocalEgress {
/// accept-stage failures → [`BindAcceptError`]. /// accept-stage failures → [`BindAcceptError`].
/// ///
/// **Address policy is the caller's**: `bind_host` is taken /// **Address policy is the caller's**: `bind_host` is taken
/// explicitly — loopback (`127.0.0.1`) is the POC's posture; a /// explicitly; loopback (`127.0.0.1`) is the POC's posture; a
/// wildcard bind is the caller's explicit choice, spelled here. /// wildcard bind is the caller's explicit choice, spelled here.
pub fn bind_accept(bind_host: impl Into<std::net::IpAddr>) -> BindAcceptFactory { pub fn bind_accept(bind_host: impl Into<std::net::IpAddr>) -> BindAcceptFactory {
let bind_host = bind_host.into(); let bind_host = bind_host.into();
+33 -33
View File
@@ -1,41 +1,41 @@
//! The producer-side local backend (feature `local`; producer.md //! The producer-side local backend (feature `local`; producer.md
//! §Producer-Side Local Backend): the same door shape in the other //! §Producer-Side Local Backend): the same door shape in the other
//! direction — the producer serves from a real `TcpListener`, so //! direction: the producer serves from a real `TcpListener`, so
//! vanilla clients reach a *locally-served* SOCKS5 resource (the //! vanilla clients reach a *locally-served* SOCKS5 resource (the
//! ssh `-D` capability hosted on the producer side). Each accepted //! ssh `-D` capability hosted on the producer side). Each accepted
//! TCP connection is handed to the landed substrate-free entry //! TCP connection is handed to the landed substrate-free entry
//! [`Socks5Producer::serve_socks5_over`] directly — the RFC //! [`Socks5Producer::serve_socks5_over`] directly; the RFC
//! conversation runs on the accepted socket, the seams dial locally //! conversation runs on the accepted socket, the seams dial locally
//! (`tcp_dial()` / `udp_egress()` / `bind_accept()`). No new //! (`tcp_dial()` / `udp_egress()` / `bind_accept()`). No new
//! protocol-layer code: this module is pure accept-loop assembly. //! protocol-layer code: this module is pure accept-loop assembly.
//! //!
//! **The door surface** (ADR-005): the NoAuth shape is the primary //! **The door surface** (ADR-005): the NoAuth shape is the primary
//! one here — the producer IS the RFC conversation on this path //! one here: the producer IS the RFC conversation on this path
//! (`serve_socks5_over` runs `accept_no_auth` in-stream); there is no //! (`serve_socks5_over` runs `accept_no_auth` in-stream); there is no
//! door-side protocol half to re-originate. A cred-bearing variant //! door-side protocol half to re-originate. A cred-bearing variant
//! (RFC 1929) would wrap `accept_password_auth` before //! (RFC 1929) would wrap `accept_password_auth` before
//! `serve_socks5_over` — documented follow-up, not landed (the task //! `serve_socks5_over` remains a documented follow-up, not landed (the task
//! spec lands the NoAuth shape unless a test drives the other). //! spec lands the NoAuth shape unless a test drives the other).
//! //!
//! **The accept path owns the idle defense + the session cap** //! **The accept path owns the idle defense + the session cap**
//! (ADR-014's accept-path defense; an accepted socket is not a //! (ADR-014's accept-path defense; an accepted socket is not a
//! channel — the cap regime does not bound it): the idle-first-byte //! channel; the cap regime does not bound it): the idle-first-byte
//! peek (non-consuming — the producer's own greeting read picks the //! peek (non-consuming, as the producer's own greeting read picks the
//! byte up) and the max-concurrent-sessions cap (an accept beyond //! byte up) and the max-concurrent-sessions cap (an accept beyond
//! the bound is refused door-side, the socket closed immediately, no //! the bound is refused door-side, the socket closed immediately, no
//! session spawned) are the same two knobs the consumer door carries //! session spawned) are the same two knobs the consumer door carries
//! ([`crate::door::FrontDoor`]). //! ([`crate::door::FrontDoor`]).
//! //!
//! **The BIND command gate** on this path is the builder's //! **The BIND command gate** on this path is the builder's
//! `bind_policy` (the substrate-free entry's gate — ADR-006's //! `bind_policy` (the substrate-free entry's gate; ADR-006's
//! posture). The pump handler's identity-derived gate does **not** //! posture). The pump handler's identity-derived gate does **not**
//! apply here: this path has no open-op identity — the accepted //! apply here: this path has no open-op identity, and the accepted
//! socket is the whole caller; whatever policy governs it is the //! socket is the whole caller; whatever policy governs it is the
//! builder's explicit `bind_policy(bool)` and the two accept-path //! builder's explicit `bind_policy(bool)` and the two accept-path
//! knobs. //! knobs.
//! //!
//! **Lifetime** (the alktunnels `TunnelListener` teardown posture): //! **Lifetime** (the alktunnels `TunnelListener` teardown posture):
//! the accept loop runs until the caller ends it — [`ProducerDoor`] //! the accept loop runs until the caller ends it; [`ProducerDoor`]
//! hands back a guard whose `close()` awaits the loop's end; dropping //! hands back a guard whose `close()` awaits the loop's end; dropping
//! the guard is a best-effort abort (the spawned sessions own their //! the guard is a best-effort abort (the spawned sessions own their
//! sockets; an in-flight session is not force-closed by a drop, only //! sockets; an in-flight session is not force-closed by a drop, only
@@ -51,13 +51,13 @@ use tokio::sync::{watch, Semaphore};
use crate::producer::Socks5Producer; use crate::producer::Socks5Producer;
/// Why one accepted connection's serving task ended (the backend's /// Why one accepted connection's serving task ended (the backend's
/// per-connection teardown diagnostic — logged + isolated; the loop /// per-connection teardown diagnostic, logged + isolated; the loop
/// never exits on a per-connection failure). /// never exits on a per-connection failure).
#[derive(Debug)] #[derive(Debug)]
enum BackendExit { enum BackendExit {
/// The vanilla client went away before/after the greeting. /// The vanilla client went away before/after the greeting.
ClientGone, ClientGone,
/// The RFC conversation ended (its `SocksError` — the mapped /// The RFC conversation ended (its `SocksError`: the mapped
/// refusal, the protocol garbage, or the session end). /// refusal, the protocol garbage, or the session end).
Session(crate::SocksError), Session(crate::SocksError),
/// A plain I/O failure on the accepted socket. /// A plain I/O failure on the accepted socket.
@@ -75,7 +75,7 @@ impl std::fmt::Display for BackendExit {
} }
/// The producer-side local backend (feature `local`): a real /// The producer-side local backend (feature `local`): a real
/// `TcpListener` front door for [`Socks5Producer`] — each accepted /// `TcpListener` front door for [`Socks5Producer`]; each accepted
/// TCP connection is served via `serve_socks5_over` (the NoAuth /// TCP connection is served via `serve_socks5_over` (the NoAuth
/// in-stream RFC conversation; the seams dial locally). The accept /// in-stream RFC conversation; the seams dial locally). The accept
/// path owns the two ADR-014 defense knobs (the idle-first-byte /// path owns the two ADR-014 defense knobs (the idle-first-byte
@@ -95,7 +95,7 @@ impl ProducerDoor {
} }
/// The builder for [`ProducerDoor`]. The producer and the bind /// The builder for [`ProducerDoor`]. The producer and the bind
/// address are required (the explicit bind decision — never implied); /// address are required (the explicit bind decision, never implied);
/// the two defense knobs carry the consumer door's defaults. /// the two defense knobs carry the consumer door's defaults.
#[derive(Default)] #[derive(Default)]
pub struct ProducerDoorBuilder { pub struct ProducerDoorBuilder {
@@ -107,16 +107,16 @@ pub struct ProducerDoorBuilder {
impl ProducerDoorBuilder { impl ProducerDoorBuilder {
/// The producer whose `serve_socks5_over` serves every accepted /// The producer whose `serve_socks5_over` serves every accepted
/// connection (the landed assembly shape — its seams dial/egress/ /// connection (the landed assembly shape, with its seams dial/egress/
/// accept; its `bind_policy` is this path's BIND gate). /// accept; its `bind_policy` is this path's BIND gate).
pub fn producer(mut self, producer: Socks5Producer) -> Self { pub fn producer(mut self, producer: Socks5Producer) -> Self {
self.producer = Some(producer); self.producer = Some(producer);
self self
} }
/// The local bind address — the backend's explicit bind decision /// The local bind address: the backend's explicit bind decision
/// (the never-binds posture: the protocol layer never binds; this /// (the never-binds posture: the protocol layer never binds; this
/// is the assembly layer's spelled-out choice — loopback is the /// is the assembly layer's spelled-out choice, with loopback the
/// documented posture). /// documented posture).
pub fn bind(mut self, addr: impl Into<SocketAddr>) -> Self { pub fn bind(mut self, addr: impl Into<SocketAddr>) -> Self {
self.bind_addr = Some(addr.into()); self.bind_addr = Some(addr.into());
@@ -126,7 +126,7 @@ impl ProducerDoorBuilder {
/// The idle-first-byte window (ADR-014 knob 1): an accepted /// The idle-first-byte window (ADR-014 knob 1): an accepted
/// connection that sends no first byte within this window is /// connection that sends no first byte within this window is
/// closed door-side. The window covers the first byte of the RFC /// closed door-side. The window covers the first byte of the RFC
/// conversation — the greeting (`05 ..`), read producer-side by /// conversation, the greeting (`05 ..`), read producer-side by
/// `serve_socks5_over`'s own handshake (the peek is non-consuming). /// `serve_socks5_over`'s own handshake (the peek is non-consuming).
pub fn idle_first_byte(mut self, window: Duration) -> Self { pub fn idle_first_byte(mut self, window: Duration) -> Self {
self.idle_first_byte = Some(window); self.idle_first_byte = Some(window);
@@ -134,10 +134,10 @@ impl ProducerDoorBuilder {
} }
/// The max-concurrent-*connections* cap (ADR-014 knob 2; review /// The max-concurrent-*connections* cap (ADR-014 knob 2; review
/// 003 N-3's naming — the mechanism bounds accepted connections, /// 003 N-3's naming: the mechanism bounds accepted connections,
/// including ones past the idle gate that are merely slow /// including ones past the idle gate that are merely slow
/// readers, not active RFC sessions): an accept beyond the bound /// readers, not active RFC sessions): an accept beyond the bound
/// is refused door-side — the socket is closed immediately, no /// is refused door-side; the socket is closed immediately, no
/// session spawned. /// session spawned.
pub fn max_sessions(mut self, cap: usize) -> Self { pub fn max_sessions(mut self, cap: usize) -> Self {
self.max_sessions = Some(cap); self.max_sessions = Some(cap);
@@ -158,20 +158,20 @@ impl ProducerDoorBuilder {
} }
/// Why the producer-door builder refused to construct (the builder's /// Why the producer-door builder refused to construct (the builder's
/// typed construction error — a missing required field; convention 2: /// typed construction error, a missing required field; convention 2:
/// no panic path). /// no panic path).
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ProducerDoorBuildError { pub enum ProducerDoorBuildError {
#[error("the backend requires a producer (.producer) — there is nothing to serve")] #[error("the backend requires a producer (.producer); there is nothing to serve")]
MissingProducer, MissingProducer,
} }
/// Why the producer door's `serve` refused to start (the typed /// Why the producer door's `serve` refused to start (the typed
/// construction error — `serve` carries the bind decision, so an /// construction error: `serve` carries the bind decision, so an
/// omitted `.bind` is its error, not a panic; convention 2). /// omitted `.bind` is its error, not a panic; convention 2).
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ProducerDoorError { pub enum ProducerDoorError {
#[error("the backend requires a bind address (.bind) before serve — the bind decision is never implied")] #[error("the backend requires a bind address (.bind) before serve; the bind decision is never implied")]
MissingBindAddr, MissingBindAddr,
#[error("tcp bind: {0}")] #[error("tcp bind: {0}")]
Bind(#[source] std::io::Error), Bind(#[source] std::io::Error),
@@ -179,7 +179,7 @@ pub enum ProducerDoorError {
/// The serving guard: [`ProducerDoor::serve`] returns one; `close()` /// The serving guard: [`ProducerDoor::serve`] returns one; `close()`
/// ends the accept loop gracefully (the listener is dropped, the /// ends the accept loop gracefully (the listener is dropped, the
/// loop's end is awaited — the spawned sessions already running own /// loop's end is awaited; the spawned sessions already running own
/// their sockets and finish on their own), and dropping it aborts the /// their sockets and finish on their own), and dropping it aborts the
/// loop task best-effort (the alktunnels `TunnelListener` teardown /// loop task best-effort (the alktunnels `TunnelListener` teardown
/// posture). /// posture).
@@ -190,12 +190,12 @@ pub struct ProducerDoorGuard {
impl ProducerDoorGuard { impl ProducerDoorGuard {
/// End the accept loop gracefully: the loop task returns, and the /// End the accept loop gracefully: the loop task returns, and the
/// guard awaits its end. In-flight sessions are NOT force-closed — /// guard awaits its end. In-flight sessions are NOT force-closed;
/// they own their sockets and finish with their clients (per- /// they own their sockets and finish with their clients (per-
/// connection isolation cuts both ways). Returns the loop task's /// connection isolation cuts both ways). Returns the loop task's
/// fatal outcome, if any (`None` = the loop ended by close). /// fatal outcome, if any (`None` = the loop ended by close).
/// ///
/// The receiver is dropped before the await — the loop's /// The receiver is dropped before the await: the loop's
/// `done_tx.closed()` fires only when every receiver is gone; /// `done_tx.closed()` fires only when every receiver is gone;
/// holding it across the await would deadlock the graceful stop. /// holding it across the await would deadlock the graceful stop.
pub async fn close(mut self) -> Option<std::io::Error> { pub async fn close(mut self) -> Option<std::io::Error> {
@@ -211,7 +211,7 @@ impl ProducerDoorGuard {
impl Drop for ProducerDoorGuard { impl Drop for ProducerDoorGuard {
/// Best-effort abort: drop the loop task's handle (the loop ends /// Best-effort abort: drop the loop task's handle (the loop ends
/// when its listener drops — the listener is owned by the task; /// when its listener drops, since the listener is owned by the task;
/// an abort cancels the accept wait). Sync-only, never awaits. /// an abort cancels the accept wait). Sync-only, never awaits.
fn drop(&mut self) { fn drop(&mut self) {
if let Some(task) = self.task.take() { if let Some(task) = self.task.take() {
@@ -222,7 +222,7 @@ impl Drop for ProducerDoorGuard {
impl ProducerDoor { impl ProducerDoor {
/// Bind the configured address and run the accept loop. Per- /// Bind the configured address and run the accept loop. Per-
/// connection failures are logged + isolated — the loop continues /// connection failures are logged + isolated; the loop continues
/// (the alktunnels per-accept isolation posture). The listener's /// (the alktunnels per-accept isolation posture). The listener's
/// lifetime is the caller's: the returned [`ProducerDoorGuard`] /// lifetime is the caller's: the returned [`ProducerDoorGuard`]
/// ends it (`close()` graceful; drop best-effort abort). /// ends it (`close()` graceful; drop best-effort abort).
@@ -246,7 +246,7 @@ impl ProducerDoor {
} }
/// Run the accept loop over an existing listener (the test/ /// Run the accept loop over an existing listener (the test/
/// assembly seam — the same accept loop without the bind). /// assembly seam: the same accept loop without the bind).
pub async fn serve_on(self, listener: TcpListener) -> ProducerDoorGuard { pub async fn serve_on(self, listener: TcpListener) -> ProducerDoorGuard {
let ProducerDoor { let ProducerDoor {
producer, producer,
@@ -280,11 +280,11 @@ fn serve_on_parts(
}; };
// The session cap (ADR-014 knob 2): refuse // The session cap (ADR-014 knob 2): refuse
// beyond the bound — `try_acquire` keeps the // beyond the bound; `try_acquire` keeps the
// accept loop free (a permit returns when a // accept loop free (a permit returns when a
// serving task ends and drops its guard). // serving task ends and drops its guard).
let Ok(permit) = live.clone().try_acquire_owned() else { let Ok(permit) = live.clone().try_acquire_owned() else {
tracing::warn!(peer = %peer, "producer door: session cap reached — refusing"); tracing::warn!(peer = %peer, "producer door: session cap reached, refusing");
drop(stream); drop(stream);
continue; continue;
}; };
@@ -306,9 +306,9 @@ fn serve_on_parts(
} }
/// Serve one accepted connection: the idle-first-byte defense first /// Serve one accepted connection: the idle-first-byte defense first
/// (ADR-014 knob 1 — the peek waits for readability without /// (ADR-014 knob 1: the peek waits for readability without
/// consuming; the producer's own greeting read picks the byte up), /// consuming; the producer's own greeting read picks the byte up),
/// then the substrate-free entry (`serve_socks5_over` — the NoAuth /// then the substrate-free entry (`serve_socks5_over`, the NoAuth
/// in-stream RFC conversation; the seams dial locally). /// in-stream RFC conversation; the seams dial locally).
async fn serve_accepted( async fn serve_accepted(
stream: tokio::net::TcpStream, stream: tokio::net::TcpStream,
+24 -24
View File
@@ -1,6 +1,6 @@
//! The local providers' unit tests: real sockets — the echo TCP //! The local providers' unit tests: real sockets, including the echo
//! server for [`super::tcp_dial`] round-trips, a real UDP echo for //! TCP server for [`super::tcp_dial`] round-trips, a real UDP echo for
//! the egress ([`super::LocalEgress`], the POC #2 shape — including //! the egress ([`super::LocalEgress`], the POC #2 shape, including
//! the readiness contract's park-then-datagram shape), a real BIND //! the readiness contract's park-then-datagram shape), a real BIND
//! accept ([`super::bind_accept`], reply#1/reply#2 carrying the real //! accept ([`super::bind_accept`], reply#1/reply#2 carrying the real
//! addresses), and the refusal mappings table-tested against the //! addresses), and the refusal mappings table-tested against the
@@ -19,7 +19,7 @@ use crate::rfc1928::consts;
const BOUND: Duration = Duration::from_secs(10); const BOUND: Duration = Duration::from_secs(10);
/// Decode the inner bytes' `(addr, payload)` shape — the forked /// Decode the inner bytes' `(addr, payload)` shape: the forked
/// `read_address` codec applied to the egress's reply (an honest /// `read_address` codec applied to the egress's reply (an honest
/// decode through the codec, not a hand-parse: the inner bytes are /// decode through the codec, not a hand-parse: the inner bytes are
/// `ATYP + addr + port + data`; the codec reads exactly the address /// `ATYP + addr + port + data`; the codec reads exactly the address
@@ -68,7 +68,7 @@ async fn echo_tcp_server() -> SocketAddr {
} }
/// A UDP echo server: datagrams echo back from their sender address /// A UDP echo server: datagrams echo back from their sender address
/// (the egress test's far side — the POC #2 shape). /// (the egress test's far side; the POC #2 shape).
async fn echo_udp_server() -> SocketAddr { async fn echo_udp_server() -> SocketAddr {
let sock = UdpSocket::bind("127.0.0.1:0").await.expect("bind"); let sock = UdpSocket::bind("127.0.0.1:0").await.expect("bind");
let addr = sock.local_addr().expect("local_addr"); let addr = sock.local_addr().expect("local_addr");
@@ -90,7 +90,7 @@ async fn echo_udp_server() -> SocketAddr {
/// The happy path: dial the echo server, round-trip bytes through the /// The happy path: dial the echo server, round-trip bytes through the
/// boxed halves (ownership rides the handle; the halves are /// boxed halves (ownership rides the handle; the halves are
/// Send+Sync+Unpin — the boxing compiles, pinned here). /// Send+Sync+Unpin; the boxing compiles, pinned here).
#[tokio::test] #[tokio::test]
async fn tcp_dial_round_trips_through_the_boxed_halves() { async fn tcp_dial_round_trips_through_the_boxed_halves() {
let server = echo_tcp_server().await; let server = echo_tcp_server().await;
@@ -110,7 +110,7 @@ async fn tcp_dial_round_trips_through_the_boxed_halves() {
} }
/// The `to_target_addr`-shaped target (the `impl ToTargetAddr` /// The `to_target_addr`-shaped target (the `impl ToTargetAddr`
/// consumer path): a domain that resolves here (`localhost` — the /// consumer path): a domain that resolves here (`localhost`; the
/// `-D` semantic: resolution is the provider's job) reaches the same /// `-D` semantic: resolution is the provider's job) reaches the same
/// echo server; the seam received it unresolved and dialed the /// echo server; the seam received it unresolved and dialed the
/// resolved address. /// resolved address.
@@ -133,7 +133,7 @@ async fn tcp_dial_resolves_domain_targets_here() {
} }
/// An unroutable loopback port refuses: `ConnectionRefused` (0x05) /// An unroutable loopback port refuses: `ConnectionRefused` (0x05)
/// per the io error kind — the refusal rides the seam's error /// per the io error kind; the refusal rides the seam's error
/// vocabulary (`to_reply_error()`), table-pinned through the mapped /// vocabulary (`to_reply_error()`), table-pinned through the mapped
/// code. /// code.
#[tokio::test] #[tokio::test]
@@ -157,7 +157,7 @@ async fn tcp_dial_connect_refused_maps_faithfully() {
); );
} }
/// A domain that does not resolve → `HostUnreachable` (0x04) — the /// A domain that does not resolve → `HostUnreachable` (0x04): the
/// resolution failure is the dial seam's error, faithfully mapped. /// resolution failure is the dial seam's error, faithfully mapped.
#[tokio::test] #[tokio::test]
async fn tcp_dial_unresolvable_domain_maps_to_host_unreachable() { async fn tcp_dial_unresolvable_domain_maps_to_host_unreachable() {
@@ -178,8 +178,8 @@ async fn tcp_dial_unresolvable_domain_maps_to_host_unreachable() {
/// The datagram round-trip through a real UDP echo (the POC #2 /// The datagram round-trip through a real UDP echo (the POC #2
/// shape): `send` (payload-only) → the echo → `next_reply`'s inner /// shape): `send` (payload-only) → the echo → `next_reply`'s inner
/// bytes carry the sender's address + the data (no length prefix — /// bytes carry the sender's address + the data (no length prefix, as
/// framing-once; the wire layer adds it downstream). /// framing happens once; the wire layer adds it downstream).
#[tokio::test] #[tokio::test]
async fn udp_egress_round_trips_inner_bytes_with_sender_address() { async fn udp_egress_round_trips_inner_bytes_with_sender_address() {
let echo = echo_udp_server().await; let echo = echo_udp_server().await;
@@ -202,14 +202,14 @@ async fn udp_egress_round_trips_inner_bytes_with_sender_address() {
assert_eq!(frame, Bytes::from(expect), "inner bytes, framed-once"); assert_eq!(frame, Bytes::from(expect), "inner bytes, framed-once");
// The sender's address decodes as the echo's (the RFC's // The sender's address decodes as the echo's (the RFC's
// per-datagram source semantics — the address rides the frame). // per-datagram source semantics; the address rides the frame).
let (src, payload) = decode_inner(&frame).await; let (src, payload) = decode_inner(&frame).await;
assert_eq!(src, dest); assert_eq!(src, dest);
assert_eq!(&payload[..], b"query"); assert_eq!(&payload[..], b"query");
} }
/// The readiness contract with the real socket: `next_reply` parks /// The readiness contract with the real socket: `next_reply` parks
/// (no reply is ready → `Pending`) — the stage's select! branch would /// (no reply is ready → `Pending`); the stage's select! branch would
/// stay parked; a datagram arriving resolves it. Pinned by racing the /// stay parked; a datagram arriving resolves it. Pinned by racing the
/// wait against a `sleep`: no reply → not resolved by the bound; the /// wait against a `sleep`: no reply → not resolved by the bound; the
/// datagram → resolved. Never a hot `Ok(None)` (the send-then-drain /// datagram → resolved. Never a hot `Ok(None)` (the send-then-drain
@@ -238,7 +238,7 @@ async fn udp_egress_next_reply_parks_until_a_datagram_arrives() {
assert_eq!(&payload[..], b"park"); assert_eq!(&payload[..], b"park");
// The socket drained: a second wait parks again (no reply is // The socket drained: a second wait parks again (no reply is
// ready) — resolved only by a fresh datagram. // ready), resolved only by a fresh datagram.
egress egress
.send(TargetAddr::Ip(echo), Bytes::from_static(b"again")) .send(TargetAddr::Ip(echo), Bytes::from_static(b"again"))
.await .await
@@ -261,7 +261,7 @@ async fn udp_egress_next_reply_parks_until_a_datagram_arrives() {
assert_eq!(&payload[..], b"again"); assert_eq!(&payload[..], b"again");
} }
/// Multiple datagrams in order (no flow table — per-datagram /// Multiple datagrams in order (no flow table; per-datagram
/// addressing does the demultiplexing; the socket is the relay /// addressing does the demultiplexing; the socket is the relay
/// handle). /// handle).
#[tokio::test] #[tokio::test]
@@ -287,7 +287,7 @@ async fn udp_egress_round_trips_multiple_datagrams_in_order() {
} }
/// A zero-length payload is a legal empty datagram (the F-2 /// A zero-length payload is a legal empty datagram (the F-2
/// invariant at payload level) — round-trips through the real /// invariant at payload level) and round-trips through the real
/// socket. /// socket.
#[tokio::test] #[tokio::test]
async fn udp_egress_zero_length_payload_round_trips() { async fn udp_egress_zero_length_payload_round_trips() {
@@ -307,7 +307,7 @@ async fn udp_egress_zero_length_payload_round_trips() {
} }
/// An undecodable destination (an unresolvable domain) fails the /// An undecodable destination (an unresolvable domain) fails the
/// `send` with `Unreachable` → `NetworkUnreachable` (0x03) — the /// `send` with `Unreachable` → `NetworkUnreachable` (0x03); the
/// resolve failure is the egress's error vocabulary. /// resolve failure is the egress's error vocabulary.
#[tokio::test] #[tokio::test]
async fn udp_egress_unresolvable_destination_maps_to_unreachable() { async fn udp_egress_unresolvable_destination_maps_to_unreachable() {
@@ -366,7 +366,7 @@ async fn bind_accept_reply1_real_addr_reply2_real_peer_and_round_trip() {
); );
// The client echoes what it receives (the round-trip's far side: // The client echoes what it receives (the round-trip's far side:
// the app server the RFC models — active-mode FTP's data // the app server the RFC models, i.e. active-mode FTP's data
// listener echo). // listener echo).
let mut client_to_echo = client; let mut client_to_echo = client;
tokio::spawn(async move { tokio::spawn(async move {
@@ -409,7 +409,7 @@ async fn bind_accept_reply1_real_addr_reply2_real_peer_and_round_trip() {
/// The requested port is honored when non-zero (FTP active mode): /// The requested port is honored when non-zero (FTP active mode):
/// the listener binds exactly the requested port, and a held port /// the listener binds exactly the requested port, and a held port
/// refuses the factory (`Shortage`/`Refused` per the io error — the /// refuses the factory (`Shortage`/`Refused` per the io error; the
/// bind failure is reply#1's vocabulary). /// bind failure is reply#1's vocabulary).
#[tokio::test] #[tokio::test]
async fn bind_accept_honors_a_requested_port_and_refuses_a_held_one() { async fn bind_accept_honors_a_requested_port_and_refuses_a_held_one() {
@@ -433,7 +433,7 @@ async fn bind_accept_honors_a_requested_port_and_refuses_a_held_one() {
); );
// A free port is honored exactly (the listener is the factory's // A free port is honored exactly (the listener is the factory's
// resource — dropped with the accept future). // resource, dropped with the accept future).
let free = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let free = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let want = free.local_addr().expect("local_addr"); let want = free.local_addr().expect("local_addr");
drop(free); drop(free);
@@ -445,7 +445,7 @@ async fn bind_accept_honors_a_requested_port_and_refuses_a_held_one() {
} }
/// The accept future owns its listener end-to-end: dropping the /// The accept future owns its listener end-to-end: dropping the
/// `BindAccept` (the quiet wait's drop — the client-gone teardown) /// `BindAccept` (the quiet wait's drop, i.e. the client-gone teardown)
/// closes the listener; the advertised address stops accepting. A /// closes the listener; the advertised address stops accepting. A
/// fresh factory on the same (now free) port re-binds. /// fresh factory on the same (now free) port re-binds.
#[tokio::test] #[tokio::test]
@@ -458,7 +458,7 @@ async fn bind_accept_drop_of_the_accept_future_closes_the_listener() {
// The listener closed with the future: a fresh bind of the same // The listener closed with the future: a fresh bind of the same
// ephemeral port succeeds (the OS may hand the port to either // ephemeral port succeeds (the OS may hand the port to either
// the reuse or a fresh allocation — loopback ephemeral binds // the reuse or a fresh allocation; loopback ephemeral binds
// succeed either way; the probe is that the re-bind is possible // succeed either way; the probe is that the re-bind is possible
// at all, i.e. the listener was dropped, not leaked). // at all, i.e. the listener was dropped, not leaked).
let rebinder = TcpListener::bind(bind_addr).await; let rebinder = TcpListener::bind(bind_addr).await;
@@ -476,7 +476,7 @@ type DialCase = (ErrorKind, &'static dyn Fn(&SocksDialError) -> bool);
type BindCase = (ErrorKind, &'static dyn Fn(&BindAcceptFactoryError) -> bool); type BindCase = (ErrorKind, &'static dyn Fn(&BindAcceptFactoryError) -> bool);
/// The dial map is table-tested (`dial_error_of` is a pure function /// The dial map is table-tested (`dial_error_of` is a pure function
/// of `io::ErrorKind` — review 003 D-3): a refactor that swaps an arm /// of `io::ErrorKind`; review 003 D-3): a refactor that swaps an arm
/// into the `_` catch-all fails here. The two loopback-unproducible /// into the `_` catch-all fails here. The two loopback-unproducible
/// kinds (`NetworkUnreachable`, `HostUnreachable`, `TimedOut`, /// kinds (`NetworkUnreachable`, `HostUnreachable`, `TimedOut`,
/// `PermissionDenied`) ride synthetic errors of the right kind. /// `PermissionDenied`) ride synthetic errors of the right kind.
@@ -528,7 +528,7 @@ fn dial_error_of_maps_the_vocabulary_table() {
} }
/// The dial vocabulary's reply codes stay RFC-faithful (the mapped /// The dial vocabulary's reply codes stay RFC-faithful (the mapped
/// surface the producer replies with — the split's whole purpose). /// surface the producer replies with; that is the split's whole purpose).
#[test] #[test]
fn dial_vocabulary_reply_codes_table() { fn dial_vocabulary_reply_codes_table() {
use crate::egress::SocksDialError; use crate::egress::SocksDialError;
+24 -24
View File
@@ -1,20 +1,20 @@
//! The trivial open-op params, the wire-stable constants, and the //! The trivial open-op params, the wire-stable constants, and the
//! `OperationSpec` builder — `wire.md` §The Open Op, ADR-001, ADR-006. //! `OperationSpec` builder (`wire.md` §The Open Op; ADR-001, ADR-006).
//! //!
//! This module is the crate's wire surface (the one-way door): the op //! This module is the crate's wire surface (the one-way door): the op
//! id, the ALPN string, the two scope strings, and the trivial params //! id, the ALPN string, the two scope strings, and the trivial params
//! shape are frozen at first publish (ADR-001). The open-op params are //! shape are frozen at first publish (ADR-001). The open-op params are
//! **trivially empty** — the SOCKS5 target is unknown at open time (it //! **trivially empty**: the SOCKS5 target is unknown at open time (it
//! arrives in-stream post-negotiation); the establisher is a session //! arrives in-stream post-negotiation); the establisher is a session
//! gate, not a dial. The empty-object schema with //! gate, not a dial. The empty-object schema with
//! `additionalProperties: false` keeps `{}` valid forever: any future //! `additionalProperties: false` keeps `{}` valid forever: any future
//! per-session option is an *additive* schema extension (ADR-001's //! per-session option is an *additive* schema extension (ADR-001's
//! extension rule) — consumers that send `{}` stay valid. //! extension rule), so consumers that send `{}` stay valid.
//! //!
//! All three RFC 1928 commands (CONNECT, BIND, UDP ASSOCIATE) ride the //! All three RFC 1928 commands (CONNECT, BIND, UDP ASSOCIATE) ride the
//! one open op, selected in-stream at command dispatch (ADR-001); BIND //! one open op, selected in-stream at command dispatch (ADR-001); BIND
//! is scope-gated separately at that dispatch (`SOCKS5_BIND_SCOPE`, //! is scope-gated separately at that dispatch (`SOCKS5_BIND_SCOPE`,
//! ADR-006) — the command is an RFC-1928 wire fact, not a wire-format //! ADR-006): the command is an RFC-1928 wire fact, not a wire-format
//! choice of ours. //! choice of ours.
//! //!
//! The typed open-error vocabulary ([`crate::error::SocksOpenError`], //! The typed open-error vocabulary ([`crate::error::SocksOpenError`],
@@ -29,7 +29,7 @@ use alkcall::registry::spec::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
/// The open-op operation id (the one SOCKS5 session op; ADR-001 — /// The open-op operation id (the one SOCKS5 session op; ADR-001,
/// flavor-form per alkcall ADR-047: openable ALPNs are operations). /// flavor-form per alkcall ADR-047: openable ALPNs are operations).
/// All three RFC 1928 commands ride this one op. Wire-stable. /// All three RFC 1928 commands ride this one op. Wire-stable.
pub const OP_SOCKS5_OPEN: &str = "channels/socks5/sub"; pub const OP_SOCKS5_OPEN: &str = "channels/socks5/sub";
@@ -39,13 +39,13 @@ pub const OP_SOCKS5_OPEN: &str = "channels/socks5/sub";
/// per protocol. Wire-stable. /// per protocol. Wire-stable.
pub const SOCKS5_ALPN: &str = "alk/socks5"; pub const SOCKS5_ALPN: &str = "alk/socks5";
/// The scope gating SOCKS5 opens (ADR-006): proxy egress — CONNECT /// The scope gating SOCKS5 opens (ADR-006): proxy egress (CONNECT
/// and UDP ASSOCIATE. The scope-string convention /// and UDP ASSOCIATE). The scope-string convention
/// `<alpn-family>:<capability>`. Stable once published. /// `<alpn-family>:<capability>`. Stable once published.
pub const SOCKS5_OPEN_SCOPE: &str = "socks5:open"; pub const SOCKS5_OPEN_SCOPE: &str = "socks5:open";
/// The scope gating TCP BIND at command dispatch (ADR-006): the /// The scope gating TCP BIND at command dispatch (ADR-006): the
/// *inbound* capability — ingress listeners. A separate grant from /// *inbound* capability: ingress listeners. A separate grant from
/// [`SOCKS5_OPEN_SCOPE`], **never implied by it**: an identity with /// [`SOCKS5_OPEN_SCOPE`], **never implied by it**: an identity with
/// proxy egress must not implicitly get ingress listeners. Checked /// proxy egress must not implicitly get ingress listeners. Checked
/// in-stream at command read, from the per-call opener identity /// in-stream at command read, from the per-call opener identity
@@ -54,21 +54,21 @@ pub const SOCKS5_OPEN_SCOPE: &str = "socks5:open";
/// it. Stable once published. /// it. Stable once published.
pub const SOCKS5_BIND_SCOPE: &str = "socks5:bind"; pub const SOCKS5_BIND_SCOPE: &str = "socks5:bind";
/// The open-op input params (ADR-001): the trivial object. Wire-stable /// The open-op input params (ADR-001): the trivial object. Wire-stable,
/// — the shape is frozen (empty object, nothing required, nothing /// since the shape is frozen (empty object, nothing
/// else); a future per-session option is an additive schema extension, /// else); a future per-session option is an additive schema extension,
/// never a format change. /// never a format change.
/// ///
/// `deny_unknown_fields` is the loud-rejection posture: unknown fields /// `deny_unknown_fields` is the loud-rejection posture: unknown fields
/// fail at parse time, never silently ignored (matching the input /// fail at parse time, never silently ignored (matching the input
/// schema's `additionalProperties: false` — the registry's gate and /// schema's `additionalProperties: false`; the registry's gate and
/// this serde pin agree). Empty params stay valid forever. /// this serde pin agree). Empty params stay valid forever.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Socks5Params {} pub struct Socks5Params {}
impl Socks5Params { impl Socks5Params {
/// The canonical wire form: the empty object — the only valid /// The canonical wire form: the empty object, the only valid
/// params value (ADR-001). /// params value (ADR-001).
pub fn as_json() -> serde_json::Value { pub fn as_json() -> serde_json::Value {
json!({}) json!({})
@@ -79,22 +79,22 @@ impl Socks5Params {
/// `Sub`-typed, external, channel-open marker on the `alk/socks5` /// `Sub`-typed, external, channel-open marker on the `alk/socks5`
/// ALPN, scope-gated by `socks5:open`, trivial params. /// ALPN, scope-gated by `socks5:open`, trivial params.
/// ///
/// - Input schema: `{type: object}` + `additionalProperties: false` — /// - Input schema: `{type: object}` + `additionalProperties: false`;
/// empty params stay valid forever (ADR-001's additive-only /// empty params stay valid forever (ADR-001's additive-only
/// extension rule); unknown fields are rejected loudly. /// extension rule); unknown fields are rejected loudly.
/// - Reply schema: `{"channel_id": <u32>, minimum 1}` — the /// - Reply schema: `{"channel_id": <u32>, minimum 1}`, the
/// `Sub`-typed operation reply. No extra reply fields in v1 (the /// `Sub`-typed operation reply. No extra reply fields in v1 (the
/// establishment phase is a session gate and has nothing to /// establishment phase is a session gate and has nothing to
/// project; alkcall's `with_reply_field` seam remains available /// project; alkcall's `with_reply_field` seam remains available
/// additively if a future need appears — ADR-001). /// additively if a future need appears (ADR-001).
/// - `required_scopes = ["socks5:open"]` — the registry runs the ACL /// - `required_scopes = ["socks5:open"]`: the registry runs the ACL
/// before the open handler (ADR-006; the open gate is the /// before the open handler (ADR-006; the open gate is the
/// boundary). /// boundary).
/// ///
/// `error_schemas` declare the `channel:open_failed` contract /// `error_schemas` declare the `channel:open_failed` contract
/// (alkcall ADR-049 §3/§4 + ADR-016): the establisher's refusal /// (alkcall ADR-049 §3/§4 + ADR-016): the establisher's refusal
/// reasons (`dial_failed` / `unknown_resource` / `resource_shortage` /// reasons (`dial_failed` / `unknown_resource` / `resource_shortage`
/// / `handler_error` / `timeout` — the ADR-049 §3 vocabulary, the /// / `handler_error` / `timeout`: the ADR-049 §3 vocabulary, the
/// same five codes [`crate::error::SocksEstablishmentReason`] parses) /// same five codes [`crate::error::SocksEstablishmentReason`] parses)
/// plus the registry's own `FORBIDDEN` class (the ACL gate runs /// plus the registry's own `FORBIDDEN` class (the ACL gate runs
/// before the establisher; ADR-006). The wrapper's wire shape is /// before the establisher; ADR-006). The wrapper's wire shape is
@@ -139,11 +139,11 @@ pub fn socks5_open_spec() -> OperationSpec {
vec![ErrorDefinition { vec![ErrorDefinition {
code: "channel:open_failed".to_string(), code: "channel:open_failed".to_string(),
description: "The channel open failed: the registry's ACL gate refused \ description: "The channel open failed: the registry's ACL gate refused \
(FORBIDDEN — no socks5:open scope), the params payload did \ (FORBIDDEN: no socks5:open scope), the params payload did \
not match the schema (INVALID_INPUT), the per-connection \ not match the schema (INVALID_INPUT), the per-connection \
channel cap was hit (channel:too_many_channels), or the \ channel cap was hit (channel:too_many_channels), or the \
establishment phase failed (details.reason ∈ the ADR-049 \ establishment phase failed (details.reason ∈ the ADR-049 \
§3 vocabulary). No channel_id is returned — no phantom \ §3 vocabulary). No channel_id is returned, so no phantom \
channel (ADR-049)." channel (ADR-049)."
.to_string(), .to_string(),
schema: open_failed_schema, schema: open_failed_schema,
@@ -160,7 +160,7 @@ pub fn socks5_open_spec() -> OperationSpec {
.with_channel_open(ChannelOpenSpec::new(SOCKS5_ALPN)) .with_channel_open(ChannelOpenSpec::new(SOCKS5_ALPN))
.with_description( .with_description(
"Open a SOCKS5 proxy channel (RFC 1928 rides the channel BiStream; \ "Open a SOCKS5 proxy channel (RFC 1928 rides the channel BiStream; \
trivial params — the target arrives in-stream post-negotiation)", trivial params; the target arrives in-stream post-negotiation)",
) )
} }
@@ -169,7 +169,7 @@ pub fn socks5_open_spec() -> OperationSpec {
/// vocabulary. `None` for any other failure shape (the registry's /// vocabulary. `None` for any other failure shape (the registry's
/// `FORBIDDEN`, `channel:too_many_channels`, local-only failures). /// `FORBIDDEN`, `channel:too_many_channels`, local-only failures).
/// ///
/// Delegates to [`ChannelOpenError::establishment_reason`] — the /// Delegates to [`ChannelOpenError::establishment_reason`] (the
/// alktunnels helper posture. The typed vocabulary lives in /// alktunnels helper posture. The typed vocabulary lives in
/// [`crate::error`] ([`crate::error::SocksEstablishmentReason`], the /// [`crate::error`] ([`crate::error::SocksEstablishmentReason`], the
/// parsed view a consumer branches on via /// parsed view a consumer branches on via
@@ -191,7 +191,7 @@ mod tests {
assert_eq!(spec.op_type, OperationType::Sub); assert_eq!(spec.op_type, OperationType::Sub);
assert_eq!(spec.visibility, Visibility::External); assert_eq!(spec.visibility, Visibility::External);
// Trivial params: empty object, unknown fields rejected loudly // Trivial params: empty object, unknown fields rejected loudly
// (ADR-001 — empty params stay valid forever). // (ADR-001: empty params stay valid forever).
assert_eq!(spec.input_schema["type"], "object"); assert_eq!(spec.input_schema["type"], "object");
assert_eq!(spec.input_schema["additionalProperties"], json!(false)); assert_eq!(spec.input_schema["additionalProperties"], json!(false));
assert!(spec.input_schema.get("required").is_none()); assert!(spec.input_schema.get("required").is_none());
@@ -229,7 +229,7 @@ mod tests {
#[test] #[test]
fn trivial_params_reject_unknown_fields() { fn trivial_params_reject_unknown_fields() {
// The loud-rejection posture — the serde pin agrees with the // The loud-rejection posture: the serde pin agrees with the
// input schema's additionalProperties: false (ADR-001). // input schema's additionalProperties: false (ADR-001).
let v = json!({"resource": "sneaky"}); let v = json!({"resource": "sneaky"});
assert!(serde_json::from_value::<Socks5Params>(v).is_err()); assert!(serde_json::from_value::<Socks5Params>(v).is_err());
@@ -270,7 +270,7 @@ mod tests {
let enum_values = props["reason"]["enum"].as_array().expect("reason enum"); let enum_values = props["reason"]["enum"].as_array().expect("reason enum");
let reasons: Vec<&str> = enum_values.iter().filter_map(|v| v.as_str()).collect(); let reasons: Vec<&str> = enum_values.iter().filter_map(|v| v.as_str()).collect();
// The declared vocabulary is exactly the typed parse table // The declared vocabulary is exactly the typed parse table
// (crate::error::SocksEstablishmentReason) — the schema and // (crate::error::SocksEstablishmentReason); the schema and
// the typed view cannot drift. // the typed view cannot drift.
assert_eq!( assert_eq!(
reasons, reasons,
+173 -173
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -9,7 +9,7 @@ fast-socks5 v1.0.0 (https://github.com/dizda/fast-socks5) and are
carried in-tree by alksocks (extraction per ADR-011, carried in-tree by alksocks (extraction per ADR-011,
docs/architecture/decisions/011-inline-vendor.md; extraction range docs/architecture/decisions/011-inline-vendor.md; extraction range
git 57b107b..5b18d4d; fork point = the tree as of commit 5b18d4d, git 57b107b..5b18d4d; fork point = the tree as of commit 5b18d4d,
the extraction review's gate commit — ownership per ADR-013, the extraction review's gate commit; ownership per ADR-013,
docs/architecture/decisions/013-targeted-fork.md). Upstream has no docs/architecture/decisions/013-targeted-fork.md). Upstream has no
per-file license headers; this bundled notice is the license per-file license headers; this bundled notice is the license
obligation's carrier. The extraction cut and the fork's deliberate obligation's carrier. The extraction cut and the fork's deliberate
@@ -19,7 +19,7 @@ Modifications after the fork point are Copyright (c) 2026 the
alksocks developers, licensed under the crate license alksocks developers, licensed under the crate license
(MIT OR Apache-2.0, per alksocks' Cargo.toml); the upstream MIT (MIT OR Apache-2.0, per alksocks' Cargo.toml); the upstream MIT
notice above is preserved per its terms. The upstream project is notice above is preserved per its terms. The upstream project is
third-party and unmodified in place — see the ADRs for what was third-party and unmodified in place; see the ADRs for what was
changed after the fork point. changed after the fork point.
--- ---
+10 -10
View File
@@ -1,12 +1,12 @@
//! Forked from fast-socks5 v1.0.0 (upstream `src/client.rs` — //! Forked from fast-socks5 v1.0.0 (upstream `src/client.rs`;
//! extraction; ownership per ADR-013, see the crate root's `mod.rs`). //! extraction; ownership per ADR-013, see the crate root's `mod.rs`).
//! //!
//! Extracted region: the `T`-generic //! Extracted region: the `T`-generic
//! client session — [`Socks5Stream<S>`] with the `use_stream` path //! client session: [`Socks5Stream<S>`] with the `use_stream` path
//! (the version/method greeting, the method-choice read, the //! (the version/method greeting, the method-choice read, the
//! password-auth sub-handshake, the request encoder `request_header`, //! password-auth sub-handshake, the request encoder `request_header`,
//! the reply reader `read_request_reply`, and the socket accessors) //! the reply reader `read_request_reply`, and the socket accessors)
//! plus the [`AsyncRead`]/[`AsyncWrite`] impls — the consumer half's //! plus the [`AsyncRead`]/[`AsyncWrite`] impls, the consumer half's
//! stream session (ADR-002; POC #1's fit: a channels `BiStream` is //! stream session (ADR-002; POC #1's fit: a channels `BiStream` is
//! fed here directly). A `Socks5Stream` is itself a stream, so //! fed here directly). A `Socks5Stream` is itself a stream, so
//! `pump_bidi`-shaped consumers and the front doors can pipe through //! `pump_bidi`-shaped consumers and the front doors can pipe through
@@ -14,10 +14,10 @@
//! //!
//! Not forked (rfc1928.md's not-forked inventory row 2): //! Not forked (rfc1928.md's not-forked inventory row 2):
//! `Socks5Datagram` //! `Socks5Datagram`
//! (struct + impl — the kernel `UdpSocket` associate client; the //! (struct + impl: the kernel `UdpSocket` associate client; the
//! crate's associate session is first-party per ADR-003's handle //! crate's associate session is first-party per ADR-003's handle
//! swap) and the `Socks5Stream<TcpStream>` convenience impl //! swap) and the `Socks5Stream<TcpStream>` convenience impl
//! (`connect`/`connect_with_password`/`connect_raw` — the //! (`connect`/`connect_with_password`/`connect_raw`, the
//! `tokio::net::TcpStream` dialers; the consumer's transport is a //! `tokio::net::TcpStream` dialers; the consumer's transport is a
//! channels `BiStream`, not a TCP dial). Upstream //! channels `BiStream`, not a TCP dial). Upstream
//! `Config::connect_timeout` is cut with its only consumer, //! `Config::connect_timeout` is cut with its only consumer,
@@ -26,13 +26,13 @@
//! //!
//! Extraction-time adjustments (mechanical, ADR-011 §Amendment): //! Extraction-time adjustments (mechanical, ADR-011 §Amendment):
//! `log` calls dropped (the bindings they consumed renamed); `anyhow` //! `log` calls dropped (the bindings they consumed renamed); `anyhow`
//! dropped — errors stay `SocksError`-typed (the missing-target-addr //! dropped; errors stay `SocksError`-typed (the missing-target-addr
//! case at upstream `client.rs:285` uses the typed //! case at upstream `client.rs:285` uses the typed
//! [`SocksError::MissingTargetAddr`] variant instead of //! [`SocksError::MissingTargetAddr`] variant instead of
//! `anyhow::Error::msg(...).into()`); `use_password_auth`'s //! `anyhow::Error::msg(...).into()`); `use_password_auth`'s
//! `unreachable!()` (upstream `client.rs:207` — `methods.get(1)` //! `unreachable!()` (upstream `client.rs:207`, where `methods.get(1)`
//! being `Some(AuthenticationMethod::None)`) is an error return //! being `Some(AuthenticationMethod::None)`) is an error return
//! ([`SocksError::AuthenticationRejected`]) — convention 2 forbids //! ([`SocksError::AuthenticationRejected`]); convention 2 forbids
//! panics in library code, and the state is reachable only by misuse; //! panics in library code, and the state is reachable only by misuse;
//! the `Socks5Datagram` error sites (`SocksError::Other(anyhow!(...))`, //! the `Socks5Datagram` error sites (`SocksError::Other(anyhow!(...))`,
//! the `"[::]:0".parse().unwrap()` sites) go with the cut region. No //! the `"[::]:0".parse().unwrap()` sites) go with the cut region. No
@@ -41,7 +41,7 @@
//! //!
//! Tests are first-party (upstream's client test module rides the cut //! Tests are first-party (upstream's client test module rides the cut
//! net region): raw-byte mock servers over tokio duplex streams, the //! net region): raw-byte mock servers over tokio duplex streams, the
//! pinned byte sequences written directly — keeping the client's wire //! pinned byte sequences written directly, keeping the client's wire
//! expectations uncorrelated from the forked server typestate. //! expectations uncorrelated from the forked server typestate.
use crate::rfc1928::util::stream::read_exact; use crate::rfc1928::util::stream::read_exact;
@@ -208,7 +208,7 @@ where
ref username, ref username,
ref password, ref password,
}) => (username, password), }) => (username, password),
// upstream `unreachable!()` — `methods.get(1)` being // upstream `unreachable!()` (`methods.get(1)` being
// `Some(AuthenticationMethod::None)`: reachable only by // `Some(AuthenticationMethod::None)`: reachable only by
// misuse; the faithful translation is an error return, // misuse; the faithful translation is an error return,
// not a panic (convention 2). Same message as upstream's // not a panic (convention 2). Same message as upstream's
+7 -7
View File
@@ -1,12 +1,12 @@
//! The RFC 1928 core: a targeted fork of fast-socks5 v1.0.0's //! The RFC 1928 core: a targeted fork of fast-socks5 v1.0.0's
//! `T`-generic subset (MIT, third-party upstream — //! `T`-generic subset (MIT, third-party upstream:
//! github.com/dizda/fast-socks5; `/workspace/fast-socks5` is the //! github.com/dizda/fast-socks5; `/workspace/fast-socks5` is the
//! read-only reference checkout, kept for differential testing of //! read-only reference checkout, kept for differential testing of
//! RFC edge cases). //! RFC edge cases).
//! //!
//! Arrived by extraction (ADR-011 §Amendment, docs/architecture/ //! Arrived by extraction (ADR-011 §Amendment, docs/architecture/
//! decisions/011-inline-vendor.md): only the code already generic //! decisions/011-inline-vendor.md): only the code already generic
//! over `T: AsyncRead + AsyncWrite + Unpin` was copied in-tree — the //! over `T: AsyncRead + AsyncWrite + Unpin` was copied in-tree; the
//! kernel-net handlers, the `socks4` module, and the legacy `Config`/ //! kernel-net handlers, the `socks4` module, and the legacy `Config`/
//! `Authentication` layer were **not** copied. The extraction cut, //! `Authentication` layer were **not** copied. The extraction cut,
//! the fork's two carried changes (BIND support, `new_reply` //! the fork's two carried changes (BIND support, `new_reply`
@@ -15,7 +15,7 @@
//! `docs/architecture/rfc1928.md`. //! `docs/architecture/rfc1928.md`.
//! //!
//! Ownership (ADR-013, docs/architecture/decisions/013-targeted-fork.md): //! Ownership (ADR-013, docs/architecture/decisions/013-targeted-fork.md):
//! this is fully owned code — project conventions apply //! this is fully owned code, so project conventions apply
//! unconditionally (no vendor carve-outs, no verbatim preservation, //! unconditionally (no vendor carve-outs, no verbatim preservation,
//! no lint-allow posture). Fork point: the tree as of the //! no lint-allow posture). Fork point: the tree as of the
//! extraction-review commit (`5b18d4d`); extraction provenance in //! extraction-review commit (`5b18d4d`); extraction provenance in
@@ -32,13 +32,13 @@
//! `use_stream` path + the `AsyncRead`/`AsyncWrite` impls) lives in //! `use_stream` path + the `AsyncRead`/`AsyncWrite` impls) lives in
//! [`client`]. //! [`client`].
//! //!
//! License: upstream is MIT with no per-file headers — the bundled //! License: upstream is MIT with no per-file headers; the bundled
//! notice (`LICENSE`, this module's directory, v1.0.0 attribution + //! notice (`LICENSE`, this module's directory, v1.0.0 attribution +
//! the fork-point statement) is the license obligation's carrier. //! the fork-point statement) is the license obligation's carrier.
//! //!
//! Maintenance posture: this code co-evolves with the wrapper and is //! Maintenance posture: this code co-evolves with the wrapper and is
//! the crate's only RFC 1928 wire vocabulary ("no second codec, //! the crate's only RFC 1928 wire vocabulary ("no second codec,
//! ever" — ADR-011's rationale, carried). Nothing is absorbed //! ever": ADR-011's rationale, carried). Nothing is absorbed
//! silently from upstream; nothing waits on upstream. //! silently from upstream; nothing waits on upstream.
pub mod client; pub mod client;
@@ -216,7 +216,7 @@ impl ReplyError {
/// Map a wire reply code to its variant. Codes outside the RFC /// Map a wire reply code to its variant. Codes outside the RFC
/// table (`0x09`–`0xFF`, unassigned per RFC 1928 §6) map to /// table (`0x09`–`0xFF`, unassigned per RFC 1928 §6) map to
/// `GeneralFailure` — the RFC's catch-all — instead of panicking: /// `GeneralFailure`, the RFC's catch-all, instead of panicking:
/// the value comes off the wire (the client session feeds the raw /// the value comes off the wire (the client session feeds the raw
/// reply byte here), so a misbehaving peer must surface as a typed /// reply byte here), so a misbehaving peer must surface as a typed
/// error, never a panic. Upstream's `unreachable!()` arm was the /// error, never a panic. Upstream's `unreachable!()` arm was the
@@ -379,7 +379,7 @@ mod tests {
} }
/// F-1 (review): unassigned reply codes (`0x09`–`0xFF`, RFC 1928 /// F-1 (review): unassigned reply codes (`0x09`–`0xFF`, RFC 1928
/// §6) surface as `GeneralFailure` — the RFC's catch-all — never /// §6) surface as `GeneralFailure` (the RFC's catch-all), never
/// a panic. Upstream's `unreachable!()` arm was removed: the code /// a panic. Upstream's `unreachable!()` arm was removed: the code
/// comes off the wire (`read_request_reply` → `from_u8`), so a /// comes off the wire (`read_request_reply` → `from_u8`), so a
/// misbehaving peer must not crash the consumer task. /// misbehaving peer must not crash the consumer task.
+16 -16
View File
@@ -1,4 +1,4 @@
//! Forked from fast-socks5 v1.0.0 (upstream `src/server.rs` — //! Forked from fast-socks5 v1.0.0 (upstream `src/server.rs`;
//! extraction; ownership per ADR-013, see the crate root's `mod.rs`). //! extraction; ownership per ADR-013, see the crate root's `mod.rs`).
//! //!
//! Extracted region: the explicit //! Extracted region: the explicit
@@ -6,13 +6,13 @@
//! negotiation with the `AuthMethod<T>` machinery, `read_command`, //! negotiation with the `AuthMethod<T>` machinery, `read_command`,
//! `reply_success`, `reply_error`, `transfer`, `wait_on_tcp`, //! `reply_success`, `reply_error`, `transfer`, `wait_on_tcp`,
//! `ErrorContext<T>`), `SocksServerError`, and the published reply //! `ErrorContext<T>`), `SocksServerError`, and the published reply
//! encoder `new_reply` (carried change #2 — upstream's is private, //! encoder `new_reply` (carried change #2: upstream's is private,
//! `fn new_reply` at `server.rs:1380`; here it is `pub` so the //! `fn new_reply` at `server.rs:1380`; here it is `pub` so the
//! BIND reply#2 writer and consumer reply readers reuse the //! BIND reply#2 writer and consumer reply readers reuse the
//! 10/22-byte reply form instead of re-encoding it). The cut regions //! 10/22-byte reply form instead of re-encoding it). The cut regions
//! (the legacy `Socks5Server`/`Socks5Socket`/`Config`/`Authentication` //! (the legacy `Socks5Server`/`Socks5Socket`/`Config`/`Authentication`
//! layer, the `run_*` kernel-net handlers, `DnsResolveHelper`, the //! layer, the `run_*` kernel-net handlers, `DnsResolveHelper`, the
//! net-only tests) stay in `/workspace/fast-socks5` — the not-forked //! net-only tests) stay in `/workspace/fast-socks5`, the not-forked
//! inventory row 1 in `docs/architecture/rfc1928.md`. //! inventory row 1 in `docs/architecture/rfc1928.md`.
//! //!
//! Extraction-time adjustments (mechanical, ADR-011 §Amendment): //! Extraction-time adjustments (mechanical, ADR-011 §Amendment):
@@ -23,22 +23,22 @@
//! `SocksServerError::AuthenticationRejected` are cut; the variant //! `SocksServerError::AuthenticationRejected` are cut; the variant
//! stays (the typestate's `accept_password_auth` produces it). //! stays (the typestate's `accept_password_auth` produces it).
//! //!
//! Post-fork owned changes (extend, never replace — convention 17): //! Post-fork owned changes (extend, never replace, per convention 17):
//! the keep-or-cut sweep (review 002 N-1) cut the producer-replaced //! the keep-or-cut sweep (review 002 N-1) cut the producer-replaced
//! machinery — the `auth_method_enums!` macro (upstream's enum //! machinery: the `auth_method_enums!` macro (upstream's enum
//! generator) with its generated `StandardAuthentication` enum + //! generator) with its generated `StandardAuthentication` enum +
//! `allow_no_auth`, `skip_auth_this_is_not_rfc_compliant`, and //! `allow_no_auth`, `skip_auth_this_is_not_rfc_compliant`, and
//! `CheckResult`'s `Option`/`Result` impls (`bool` stays) — none had //! `CheckResult`'s `Option`/`Result` impls (`bool` stays); none had
//! an in-tree consumer once the producer drove `negotiate_auth` //! an in-tree consumer once the producer drove `negotiate_auth`
//! directly. Kept with note (the hand-port map, ADR-013): //! directly. Kept with note (the hand-port map, ADR-013):
//! `transfer`/`wait_on_tcp` are differential-suite-consumed (their //! `transfer`/`wait_on_tcp` are differential-suite-consumed (their
//! semantics stay pinned there), and the error vocabulary keeps its //! semantics stay pinned there), and the error vocabulary keeps its
//! upstream variants. One variant was added, not absorbed: //! upstream variants. One variant was added, not absorbed:
//! `SocksServerError::Refused` — the honest refusal vocabulary for //! `SocksServerError::Refused`: the honest refusal vocabulary for
//! the producer's in-stream policy refusals (review 002 N-4; never //! the producer's in-stream policy refusals (review 002 N-4; never
//! reaches the wire). Recorded in docs/architecture/rfc1928.md. //! reaches the wire). Recorded in docs/architecture/rfc1928.md.
//! //!
//! The BIND note (ADR-007): BIND's driving is producer-side code — //! The BIND note (ADR-007): BIND's driving is producer-side code;
//! the typestate already returns `TCPBind` from `read_command()`, //! the typestate already returns `TCPBind` from `read_command()`,
//! `reply_success(sock_addr)` carries reply#1's BND.ADDR, and //! `reply_success(sock_addr)` carries reply#1's BND.ADDR, and
//! reply#2 is written with the published `new_reply`. No BIND code //! reply#2 is written with the published `new_reply`. No BIND code
@@ -159,7 +159,7 @@ impl<T> Socks5ServerProtocol<T, states::Opened> {
/// Closure-result adapter for [`Socks5ServerProtocol /// Closure-result adapter for [`Socks5ServerProtocol
/// ::accept_password_auth`]'s credential checker: `bool` (`is_good`). /// ::accept_password_auth`]'s credential checker: `bool` (`is_good`).
/// The `Option`/`Result` impls upstream-adjacent shape were cut in /// The `Option`/`Result` impls upstream-adjacent shape were cut in
/// the keep-or-cut sweep (review 002 N-1) — every in-tree checker /// the keep-or-cut sweep (review 002 N-1); every in-tree checker
/// returns `bool`; re-add with a typed-rejection design if a door /// returns `bool`; re-add with a typed-rejection design if a door
/// ever wants it. /// ever wants it.
pub trait CheckResult { pub trait CheckResult {
@@ -240,7 +240,7 @@ pub trait AuthMethodSuccessState<T> {
/// Use blank structs for individual protocol implementations; /// Use blank structs for individual protocol implementations;
/// `negotiate_auth` takes any slice of `AuthMethod`s, so a custom /// `negotiate_auth` takes any slice of `AuthMethod`s, so a custom
/// selection is a slice of the method structs (upstream's /// selection is a slice of the method structs (upstream's
/// enum-generator macro was cut — review 002 N-1; nothing in-tree /// enum-generator macro was cut (review 002 N-1); nothing in-tree
/// drove a multi-method slice). /// drove a multi-method slice).
pub trait AuthMethod<T>: Copy { pub trait AuthMethod<T>: Copy {
type StartingState; type StartingState;
@@ -354,7 +354,7 @@ impl<T: AsyncWrite + Unpin> PasswordAuthenticationImpl<T, password_states::Recei
/// Notify the client with a "NOT_ACCEPTABLE" reply and drop the socket. /// Notify the client with a "NOT_ACCEPTABLE" reply and drop the socket.
/// ///
/// The status byte is `0xff` — RFC 1929 requires only that a /// The status byte is `0xff`: RFC 1929 requires only that a
/// rejection be any non-`0x00` byte; `0xff` is the convention /// rejection be any non-`0x00` byte; `0xff` is the convention
/// upstream picked (borrowed from the method-negotiation phase's /// upstream picked (borrowed from the method-negotiation phase's
/// `SOCKS5_AUTH_METHOD_NOT_ACCEPTABLE` constant, which is a /// `SOCKS5_AUTH_METHOD_NOT_ACCEPTABLE` constant, which is a
@@ -530,7 +530,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin> Socks5ServerProtocol<T, states::Authenti
} }
/// The RFC reply form's placeholder BND.ADDR for error replies /// The RFC reply form's placeholder BND.ADDR for error replies
/// (statically constructed — upstream parsed `"0.0.0.0:0"` at runtime). /// (statically constructed; upstream parsed `"0.0.0.0:0"` at runtime).
const V4_UNSPECIFIED_ZERO: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)); const V4_UNSPECIFIED_ZERO: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0));
/// Generate reply code according to the RFC. /// Generate reply code according to the RFC.
@@ -606,14 +606,14 @@ mod tests {
/// `SocksServerError::to_reply_error`'s full mapping table (review /// `SocksServerError::to_reply_error`'s full mapping table (review
/// 001 L-3): `UnknownCommand` → `CommandNotSupported`, /// 001 L-3): `UnknownCommand` → `CommandNotSupported`,
/// `AddrError` delegates to its own table, and every remaining /// `AddrError` delegates to its own table, and every remaining
/// variant falls through to `GeneralFailure` — the mapping the /// variant falls through to `GeneralFailure`, the mapping the
/// producer's error surface relies on when mapping session errors /// producer's error surface relies on when mapping session errors
/// to reply codes (`try_notify!`'s secondary-error write). The /// to reply codes (`try_notify!`'s secondary-error write). The
/// fall-through arms (`Io`/`FromUtf8`/`EOF`/auth/refusal variants) /// fall-through arms (`Io`/`FromUtf8`/`EOF`/auth/refusal variants)
/// are exercised here directly, since driving each from the wire /// are exercised here directly, since driving each from the wire
/// is not one-test-cheap. Upstream `src/server.rs:62-70`. The /// is not one-test-cheap. Upstream `src/server.rs:62-70`. The
/// table also pins the vocabulary honesty (review 002 N-4): /// table also pins the vocabulary honesty (review 002 N-4):
/// `Bug` and `Refused` are log-vocabulary variants — both fall /// `Bug` and `Refused` are log-vocabulary variants; both fall
/// through to `GeneralFailure` (they never reach the wire from /// through to `GeneralFailure` (they never reach the wire from
/// the typestate; the producer's refusal sites write the RFC /// the typestate; the producer's refusal sites write the RFC
/// reply before the session error). /// reply before the session error).
@@ -655,13 +655,13 @@ mod tests {
} }
/// The refusal vocabulary (review 002 N-4): `Refused` is the /// The refusal vocabulary (review 002 N-4): `Refused` is the
/// honest display for the producer's in-stream policy refusals — /// honest display for the producer's in-stream policy refusals:
/// not `BUG:` (a working-as-designed refusal is not a bug) and /// not `BUG:` (a working-as-designed refusal is not a bug) and
/// not "Authentication rejected" (an ACL refusal is not an auth /// not "Authentication rejected" (an ACL refusal is not an auth
/// failure). It is log/observability vocabulary: never encoded /// failure). It is log/observability vocabulary: never encoded
/// on the wire by the typestate (only `to_reply_error` maps it, /// on the wire by the typestate (only `to_reply_error` maps it,
/// and the producer's refusal sites write the RFC reply before /// and the producer's refusal sites write the RFC reply before
/// the session error). Upstream has no such variant — the /// the session error). Upstream has no such variant; that is the
/// fork's owned addition, recorded in the module doc. /// fork's owned addition, recorded in the module doc.
#[test] #[test]
fn refused_variant_display_is_honest_and_wire_invisible() { fn refused_variant_display_is_honest_and_wire_invisible() {
+2 -2
View File
@@ -1,10 +1,10 @@
//! The RFC 1928 core's utility modules (upstream `src/util/` — //! The RFC 1928 core's utility modules (upstream `src/util/`;
//! extraction; see the fork statement in the crate root's `mod.rs` //! extraction; see the fork statement in the crate root's `mod.rs`
//! and ADR-013 for ownership). //! and ADR-013 for ownership).
//! //!
//! Upstream layout: `stream.rs` (the `read_exact!`/`ready!` macros + //! Upstream layout: `stream.rs` (the `read_exact!`/`ready!` macros +
//! `ConnectError`) and `target_addr.rs` (`TargetAddr` + the address //! `ConnectError`) and `target_addr.rs` (`TargetAddr` + the address
//! codec) — filled by the wire-vocabulary extraction task. //! codec), filled by the wire-vocabulary extraction task.
pub mod stream; pub mod stream;
pub mod target_addr; pub mod target_addr;
+4 -4
View File
@@ -1,10 +1,10 @@
//! Forked from fast-socks5 v1.0.0 (upstream `src/util/stream.rs` — //! Forked from fast-socks5 v1.0.0 (upstream `src/util/stream.rs`;
//! extraction; ownership per ADR-013, see the crate root's `mod.rs`). //! extraction; ownership per ADR-013, see the crate root's `mod.rs`).
//! //!
//! Upstream carries the `read_exact!`/`ready!` macros, `ConnectError`, //! Upstream carries the `read_exact!`/`ready!` macros, `ConnectError`,
//! and the `tcp_connect*` dialers; only the `read_exact!` macro + //! and the `tcp_connect*` dialers; only the `read_exact!` macro +
//! `ConnectError` rode the extraction (the dialers and the `ready!` //! `ConnectError` rode the extraction (the dialers and the `ready!`
//! macro are the cut kernel-net region — the crate's local backend is //! macro are the cut kernel-net region: the crate's local backend is
//! first-party tokio code, ADR-008, and nothing in the extracted //! first-party tokio code, ADR-008, and nothing in the extracted
//! region used `ready!`). Extraction-time adjustment: upstream //! region used `ready!`). Extraction-time adjustment: upstream
//! exports the macro at the crate root via `#[macro_export]`; here it //! exports the macro at the crate root via `#[macro_export]`; here it
@@ -13,11 +13,11 @@
//! public API). //! public API).
//! //!
//! [`ConnectError`] is currently constructed nowhere in-tree (review //! [`ConnectError`] is currently constructed nowhere in-tree (review
//! 001 C-1 — upstream's sole construction site was the cut //! 001 C-1: upstream's sole construction site was the cut
//! `tcp_connect` dialer). The keep-or-cut trigger (cut if the //! `tcp_connect` dialer). The keep-or-cut trigger (cut if the
//! producer layer landed without consuming it) fired and the //! producer layer landed without consuming it) fired and the
//! disposition is **keep** (review 002 N-1): it stays as the //! disposition is **keep** (review 002 N-1): it stays as the
//! hand-port map's companion — the dial-error → reply-code mapping //! hand-port map's companion (the dial-error → reply-code mapping
//! (`to_reply_error`) is what a future kernel-net port (e.g. the //! (`to_reply_error`) is what a future kernel-net port (e.g. the
//! dial-callback shape, ADR-004) would reuse, and its mapping is //! dial-callback shape, ADR-004) would reuse, and its mapping is
//! pinned by the module's test. //! pinned by the module's test.
+7 -6
View File
@@ -1,10 +1,11 @@
//! Forked from fast-socks5 v1.0.0 (upstream `src/util/target_addr.rs` //! Forked from fast-socks5 v1.0.0 (upstream `src/util/target_addr.rs`;
//! — extraction; ownership per ADR-013, see the crate root's //! extraction; ownership per ADR-013, see the crate root's
//! `mod.rs`). //! `mod.rs`).
//! //!
//! Upstream carries `TargetAddr`, the address codec (`read_address`), //! Upstream carries `TargetAddr`, the address codec (`read_address`),
//! and the `resolve_dns` helper; only the codec rode the extraction //! and the `resolve_dns` helper; only the codec rode the extraction
//! (DNS resolution is the cut net region — `tokio::net::lookup_host` //! (DNS resolution is the cut net region, since
//! `tokio::net::lookup_host`
//! is not in the wasm-clean graph). The `resolve_dns` method is cut //! is not in the wasm-clean graph). The `resolve_dns` method is cut
//! with its two `AddrError` variants (`DNSResolutionFailed`, //! with its two `AddrError` variants (`DNSResolutionFailed`,
//! `NoDNSRecords`). //! `NoDNSRecords`).
@@ -110,7 +111,7 @@ impl TargetAddr {
/// The blocking `ToSocketAddrs` form: IP targets resolve directly; /// The blocking `ToSocketAddrs` form: IP targets resolve directly;
/// domain targets cannot be resolved here (domain resolution is the /// domain targets cannot be resolved here (domain resolution is the
/// egress's job — ADR-012) and error. /// egress's job, per ADR-012) and error.
impl std::net::ToSocketAddrs for TargetAddr { impl std::net::ToSocketAddrs for TargetAddr {
type Iter = IntoIter<SocketAddr>; type Iter = IntoIter<SocketAddr>;
@@ -210,7 +211,7 @@ impl ToTargetAddr for &TargetAddr {
pub enum Addr { pub enum Addr {
V4([u8; 4]), V4([u8; 4]),
V6([u8; 16]), V6([u8; 16]),
// `String` — validated UTF-8 at the codec boundary (`read_address` // `String`, validated UTF-8 at the codec boundary (`read_address`
// converts before constructing). // converts before constructing).
Domain(String), Domain(String),
} }
@@ -218,7 +219,7 @@ pub enum Addr {
/// This function is used by the client & the server. /// This function is used by the client & the server.
/// ///
/// The domain arm's allocation is bounded by construction: the wire /// The domain arm's allocation is bounded by construction: the wire
/// length field is one byte, so the claimed length is at most 255 — /// length field is one byte, so the claimed length is at most 255;
/// the attacker-controlled byte cannot ask for more. Any change that /// the attacker-controlled byte cannot ask for more. Any change that
/// widens that field (an FRAG-style extension) requires an explicit /// widens that field (an FRAG-style extension) requires an explicit
/// cap here before the allocation. /// cap here before the allocation.
+32 -32
View File
@@ -1,5 +1,5 @@
//! The ASSOCIATE datagram frame codec (ADR-003): the crate's one //! The ASSOCIATE datagram frame codec (ADR-003): the crate's one
//! framed stage — `[len: u16 BE][addr + data]` per datagram, both //! framed stage, `[len: u16 BE][addr + data]` per datagram, both
//! directions of the channel `BiStream`. CONNECT/BIND are raw //! directions of the channel `BiStream`. CONNECT/BIND are raw
//! pass-through (no framing). Normative WHAT: `wire.md` §The Data //! pass-through (no framing). Normative WHAT: `wire.md` §The Data
//! Plane; the binary contract: `bast.md` (`AssociateFrame`). The //! Plane; the binary contract: `bast.md` (`AssociateFrame`). The
@@ -7,18 +7,18 @@
//! `frame_datagram`/`DatagramReader` (same F-2 behavior; no //! `frame_datagram`/`DatagramReader` (same F-2 behavior; no
//! dependency on it). //! dependency on it).
//! //!
//! The inner bytes are the RFC 1928 address encoding — exactly //! The inner bytes are the RFC 1928 address encoding, exactly
//! `TargetAddr::to_be_bytes()` (ATYP + addr + port; the RFC UDP //! `TargetAddr::to_be_bytes()` (ATYP + addr + port; the RFC UDP
//! header minus RSV/FRAG, which ride only vanilla front doors, //! header minus RSV/FRAG, which ride only vanilla front doors,
//! ADR-003) — followed by the datagram payload. //! ADR-003), followed by the datagram payload.
//! //!
//! **Framing is applied exactly once, by this layer.** The egress //! **Framing is applied exactly once, by this layer.** The egress
//! seam speaks *inner* bytes (`addr + data`, no length prefix — //! seam speaks *inner* bytes (`addr + data`, no length prefix;
//! `Egress::next_reply` returns inner bytes); the producer's frame //! `Egress::next_reply` returns inner bytes); the producer's frame
//! writer adds the single length prefix, and the consumer's send path //! writer adds the single length prefix, and the consumer's send path
//! frames via [`frame`] exactly once. The double-framing bug class //! frames via [`frame`] exactly once. The double-framing bug class
//! POC #2 caught (an egress framing its replies, the producer //! POC #2 caught (an egress framing its replies, the producer
//! prefixing again — the client parsed the length field as ATYP //! prefixing again, the client parsed the length field as ATYP
//! `0x00`) is an API-design invariant: the wire layer owns the length //! `0x00`) is an API-design invariant: the wire layer owns the length
//! prefix; nothing else adds one. //! prefix; nothing else adds one.
//! //!
@@ -26,7 +26,7 @@
//! field covers `addr + data`, not the payload alone: a frame is //! field covers `addr + data`, not the payload alone: a frame is
//! rejected ([`WireError::Oversize`], at frame time) whenever //! rejected ([`WireError::Oversize`], at frame time) whenever
//! `len(addr) + len(payload) > 65535`. The UDP payload bound alone //! `len(addr) + len(payload) > 65535`. The UDP payload bound alone
//! (65507) does NOT fit u16 unconditionally — a 65507-byte datagram //! (65507) does NOT fit u16 unconditionally: a 65507-byte datagram
//! to a 255-character domain is 65507 + 259 = 65766 frame bytes. Max //! to a 255-character domain is 65507 + 259 = 65766 frame bytes. Max
//! payload per address form: 65276 (domain, 255 chars), 65516 (IPv6), //! payload per address form: 65276 (domain, 255 chars), 65516 (IPv6),
//! 65528 (IPv4). [`addr_encoding_len`] exposes the per-address //! 65528 (IPv4). [`addr_encoding_len`] exposes the per-address
@@ -34,9 +34,9 @@
//! u16 arithmetic is used on the payload bound. //! u16 arithmetic is used on the payload bound.
//! //!
//! **The F-2 invariant, at payload level.** A zero-length payload is //! **The F-2 invariant, at payload level.** A zero-length payload is
//! a legal empty datagram — a real datagram, never an EOF signal: //! a legal empty datagram (a real datagram, never an EOF signal):
//! [`frame`] encodes it and [`DatagramReader`] decodes it as //! [`frame`] encodes it and [`DatagramReader`] decodes it as
//! `(addr, empty)`. (`len == 0` — zero inner bytes — is unreachable //! `(addr, empty)`. (`len == 0`, zero inner bytes, is unreachable
//! from a compliant peer, since `addr` is always ≥ 7 bytes; a wire //! from a compliant peer, since `addr` is always ≥ 7 bytes; a wire
//! `len` of 0 is protocol garbage and surfaces as //! `len` of 0 is protocol garbage and surfaces as
//! [`WireError::EmptyFrame`].) //! [`WireError::EmptyFrame`].)
@@ -44,11 +44,11 @@
//! **EOF layering.** EOF is the channels-level sentinel on the //! **EOF layering.** EOF is the channels-level sentinel on the
//! `BiStream`, outside this codec: the codec never emits zero-length //! `BiStream`, outside this codec: the codec never emits zero-length
//! reads, so the sentinel and the datagram stream never collide (an //! reads, so the sentinel and the datagram stream never collide (an
//! empty datagram and a zero-byte read are different shapes — the //! empty datagram and a zero-byte read are different shapes; that is
//! reason boundary-preserving framing is mandatory for the datagram //! why boundary-preserving framing is mandatory for the datagram
//! stage at all). A stream end mid-frame is the session's fail-loud //! stage at all). A stream end mid-frame is the session's fail-loud
//! truncated-datagram condition ([`DatagramReader::is_mid_frame`] is //! truncated-datagram condition ([`DatagramReader::is_mid_frame`] is
//! the teardown diagnostic); the codec itself only buffers — it never //! the teardown diagnostic); the codec itself only buffers; it never
//! errors on truncation and never yields a partial frame. //! errors on truncation and never yields a partial frame.
use bytes::{Buf, BufMut, Bytes, BytesMut}; use bytes::{Buf, BufMut, Bytes, BytesMut};
@@ -60,7 +60,7 @@ use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
/// The u16 length-field bound on a frame's inner bytes (`addr + data`): /// The u16 length-field bound on a frame's inner bytes (`addr + data`):
/// frames whose address encoding plus payload exceed this are rejected /// frames whose address encoding plus payload exceed this are rejected
/// at frame time (ADR-003 — never a wire overflow). The joint bound, /// at frame time (ADR-003: never a wire overflow). The joint bound,
/// not a payload bound: see the module docs. /// not a payload bound: see the module docs.
pub const MAX_INNER_LEN: usize = u16::MAX as usize; pub const MAX_INNER_LEN: usize = u16::MAX as usize;
@@ -69,12 +69,12 @@ const FRAME_LEN_FIELD: usize = 2;
/// Codec errors. Two families: /// Codec errors. Two families:
/// ///
/// - **Frame time** ([`frame`]): [`WireError::Oversize`] (the joint /// - **Frame time** ([`frame`]): [`WireError::Oversize`] (the joint
/// bound — `len(addr) + len(payload) > 65535` would wrap the u16 /// bound; `len(addr) + len(payload) > 65535` would wrap the u16
/// length field) and [`WireError::Address`] (the address encoding /// length field) and [`WireError::Address`] (the address encoding
/// refused — e.g. a domain longer than 255 bytes). /// refused, e.g. a domain longer than 255 bytes).
/// - **Decode time** ([`DatagramReader::feed`], per completed frame): /// - **Decode time** ([`DatagramReader::feed`], per completed frame):
/// [`WireError::EmptyFrame`], [`WireError::BadAtyp`], /// [`WireError::EmptyFrame`], [`WireError::BadAtyp`],
/// [`WireError::ShortAddress`], [`WireError::DomainNotUtf8`] — all /// [`WireError::ShortAddress`], [`WireError::DomainNotUtf8`]: all
/// protocol-garbage shapes a compliant peer never produces. A bad /// protocol-garbage shapes a compliant peer never produces. A bad
/// ATYP is its own variant so the producer stage can fail the /// ATYP is its own variant so the producer stage can fail the
/// association loud (`udp_undecodable_frame_ends_association`, /// association loud (`udp_undecodable_frame_ends_association`,
@@ -87,24 +87,24 @@ const FRAME_LEN_FIELD: usize = 2;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum WireError { pub enum WireError {
/// A frame's inner bytes (`addr + data`) exceed /// A frame's inner bytes (`addr + data`) exceed
/// [`MAX_INNER_LEN`] — the u16 length field would wrap. Rejected /// [`MAX_INNER_LEN`]; the u16 length field would wrap. Rejected
/// at frame time; carries `(total, max)`. /// at frame time; carries `(total, max)`.
#[error("datagram frame too large for u16 length prefix: {0} bytes of addr + data (max {1})")] #[error("datagram frame too large for u16 length prefix: {0} bytes of addr + data (max {1})")]
Oversize(usize, usize), Oversize(usize, usize),
/// The address could not be encoded (`TargetAddr::to_be_bytes` /// The address could not be encoded (`TargetAddr::to_be_bytes`
/// refused — e.g. `AddrError::DomainLenTooLong`). /// refused, e.g. `AddrError::DomainLenTooLong`).
#[error("address encode: {0}")] #[error("address encode: {0}")]
Address(#[from] AddrError), Address(#[from] AddrError),
/// A completed frame declared `len == 0` — zero inner bytes, no /// A completed frame declared `len == 0` (zero inner bytes, no
/// address at all. Unreachable from a compliant peer (`addr` is /// address at all. Unreachable from a compliant peer (`addr` is
/// always ≥ 7 bytes); protocol garbage. /// always ≥ 7 bytes); protocol garbage.
#[error("empty frame: no address bytes (len == 0 is unreachable from a compliant peer)")] #[error("empty frame: no address bytes (len == 0 is unreachable from a compliant peer)")]
EmptyFrame, EmptyFrame,
/// The frame's first byte is not a known ATYP (`0x01`/`0x03`/ /// The frame's first byte is not a known ATYP (`0x01`/`0x03`/
/// `0x04`) — post-reply control bytes parse as exactly this (the /// `0x04`); post-reply control bytes parse as exactly this (the
/// frame's first byte is ATYP). The loud-fail shape: /// frame's first byte is ATYP). The loud-fail shape:
/// `udp_undecodable_frame_ends_association`, POC #2. /// `udp_undecodable_frame_ends_association`, POC #2.
#[error("unknown ATYP {0:#x}: protocol garbage")] #[error("unknown ATYP {0:#x}: protocol garbage")]
@@ -122,7 +122,7 @@ pub enum WireError {
} }
/// The RFC 1928 address encoding's length for `addr` (ATYP + addr + /// The RFC 1928 address encoding's length for `addr` (ATYP + addr +
/// port — exactly `TargetAddr::to_be_bytes()`'s size): 7 bytes for /// port, exactly `TargetAddr::to_be_bytes()`'s size): 7 bytes for
/// IPv4, 19 for IPv6, `4 + domain.len()` for domain form (259 max). /// IPv4, 19 for IPv6, `4 + domain.len()` for domain form (259 max).
/// Callers compute the joint bound with it: a payload is framable for /// Callers compute the joint bound with it: a payload is framable for
/// `addr` iff `addr_encoding_len(addr) + payload.len() <= MAX_INNER_LEN`. /// `addr` iff `addr_encoding_len(addr) + payload.len() <= MAX_INNER_LEN`.
@@ -137,10 +137,10 @@ pub fn addr_encoding_len(addr: &TargetAddr) -> usize {
/// Frame one datagram: `[len: u16 BE][addr + data]` where `addr + /// Frame one datagram: `[len: u16 BE][addr + data]` where `addr +
/// data` is `TargetAddr::to_be_bytes()` (ATYP + addr + port) followed /// data` is `TargetAddr::to_be_bytes()` (ATYP + addr + port) followed
/// by `payload`. One frame per datagram; the single length prefix on /// by `payload`. One frame per datagram; the single length prefix on
/// this wire (framing-once — see the module docs). A zero-length /// this wire (framing-once; see the module docs). A zero-length
/// payload is a legal empty datagram (the F-2 invariant, at payload /// payload is a legal empty datagram (the F-2 invariant, at payload
/// level). Rejects [`WireError::Oversize`] at frame time when /// level). Rejects [`WireError::Oversize`] at frame time when
/// `len(addr) + len(payload) > 65535` — the joint bound; the payload /// `len(addr) + len(payload) > 65535`, the joint bound; the payload
/// bound alone (65507) does not fit u16 unconditionally. /// bound alone (65507) does not fit u16 unconditionally.
pub fn frame(addr: &TargetAddr, payload: &[u8]) -> Result<Bytes, WireError> { pub fn frame(addr: &TargetAddr, payload: &[u8]) -> Result<Bytes, WireError> {
let addr_bytes = addr.to_be_bytes()?; let addr_bytes = addr.to_be_bytes()?;
@@ -158,7 +158,7 @@ pub fn frame(addr: &TargetAddr, payload: &[u8]) -> Result<Bytes, WireError> {
Ok(buf.freeze()) Ok(buf.freeze())
} }
/// Frame pre-encoded inner bytes (`addr + data` — exactly what /// Frame pre-encoded inner bytes (`addr + data`, exactly what
/// [`crate::egress::Egress::next_reply`] returns) as one /// [`crate::egress::Egress::next_reply`] returns) as one
/// `[len: u16 BE][inner]` frame. The reply-direction half of the /// `[len: u16 BE][inner]` frame. The reply-direction half of the
/// framing-once invariant: the egress speaks inner bytes (no length /// framing-once invariant: the egress speaks inner bytes (no length
@@ -177,14 +177,14 @@ pub fn frame_inner(inner: &[u8]) -> Result<Bytes, WireError> {
/// Incremental datagram decoder over the framed stage's read /// Incremental datagram decoder over the framed stage's read
/// direction. Feed it whatever `read()` yields; it reassembles /// direction. Feed it whatever `read()` yields; it reassembles
/// `[len: u16 BE][addr + data]` frames across chunk boundaries — a /// `[len: u16 BE][addr + data]` frames across chunk boundaries: a
/// frame may be split across chunks, several frames may share one /// frame may be split across chunks, several frames may share one
/// chunk, and partial headers wait for more bytes. Each completed /// chunk, and partial headers wait for more bytes. Each completed
/// frame is decoded into `(TargetAddr, Bytes)` — the addr sub-layout /// frame is decoded into `(TargetAddr, Bytes)`: the addr sub-layout
/// (ATYP determines the address length) is split off, the remainder /// (ATYP determines the address length) is split off, the remainder
/// is the datagram payload. /// is the datagram payload.
/// ///
/// Reassembly is infallible (see [`WireError`] docs — the oversize /// Reassembly is infallible (see the [`WireError`] docs: the oversize
/// bound is enforced at frame time); the only error per frame is the /// bound is enforced at frame time); the only error per frame is the
/// addr decode (the garbage shapes). The reader keeps going after a /// addr decode (the garbage shapes). The reader keeps going after a
/// decode error (the frame's bytes are consumed either way); the /// decode error (the frame's bytes are consumed either way); the
@@ -210,7 +210,7 @@ impl DatagramReader {
} }
/// Feed raw bytes from the read half. Returns every frame that /// Feed raw bytes from the read half. Returns every frame that
/// completed with this feed, in order — zero or more per chunk. /// completed with this feed, in order, zero or more per chunk.
/// Buffers partial frames across chunk boundaries (bounded by the /// Buffers partial frames across chunk boundaries (bounded by the
/// u16 declared length). Infallible reassembly; per-frame addr /// u16 declared length). Infallible reassembly; per-frame addr
/// decode errors are the [`WireError`] decode variants. /// decode errors are the [`WireError`] decode variants.
@@ -249,13 +249,13 @@ impl DatagramReader {
} }
/// `true` while a partial frame is in flight (the teardown /// `true` while a partial frame is in flight (the teardown
/// diagnostic — a clean peer never ends the stream mid-frame; a /// diagnostic: a clean peer never ends the stream mid-frame; a
/// stream end while this is set is the session's fail-loud /// stream end while this is set is the session's fail-loud
/// truncated-datagram condition, never a silent partial /// truncated-datagram condition, never a silent partial
/// datagram). /// datagram).
/// ///
/// Kept public for the anticipated batch-2 consumer — a front-door /// Kept public for the anticipated batch-2 consumer (a front-door
/// bridge driving its own reader loop (the doors) — so a later /// bridge driving its own reader loop, the doors), so a later
/// dead-surface sweep does not flag it (review-002 N-9). /// dead-surface sweep does not flag it (review-002 N-9).
pub fn is_mid_frame(&self) -> bool { pub fn is_mid_frame(&self) -> bool {
self.current.is_some() self.current.is_some()
@@ -273,7 +273,7 @@ impl DatagramReader {
/// Decode one frame's inner bytes (`addr + data`, the length prefix /// Decode one frame's inner bytes (`addr + data`, the length prefix
/// already consumed) into `(addr, payload)`. The first byte is ATYP; /// already consumed) into `(addr, payload)`. The first byte is ATYP;
/// the address encoding is parsed by hand over the borrowed slice — /// the address encoding is parsed by hand over the borrowed slice;
/// the same sub-layout the forked `read_address` accepts in-stream, /// the same sub-layout the forked `read_address` accepts in-stream,
/// mirrored synchronously (the stage's decode must not be async over /// mirrored synchronously (the stage's decode must not be async over
/// a borrowed frame). RSV/FRAG cannot appear (ADR-003: they do not /// a borrowed frame). RSV/FRAG cannot appear (ADR-003: they do not
@@ -567,7 +567,7 @@ mod tests {
fn frame_and_frame_inner_produce_identical_wires() { fn frame_and_frame_inner_produce_identical_wires() {
// The reply path (frame_inner over pre-encoded inner bytes) // The reply path (frame_inner over pre-encoded inner bytes)
// and the forward path (frame over addr + payload) agree // and the forward path (frame over addr + payload) agree
// byte-for-byte — one codec, two callers. // byte-for-byte (one codec, two callers).
let addr = domain("example.com", 53); let addr = domain("example.com", 53);
let payload = b"payload"; let payload = b"payload";
let mut inner = addr.to_be_bytes().unwrap().to_vec(); let mut inner = addr.to_be_bytes().unwrap().to_vec();
@@ -1,6 +1,6 @@
--- ---
id: architecture/adr-014-idle-timeout-posture id: architecture/adr-014-idle-timeout-posture
name: "ADR-014 — the idle-timeout posture for the pre-command handshake and the association (review-002 C-2)" name: "ADR-014: the idle-timeout posture for the pre-command handshake and the association (review-002 C-2)"
status: completed status: completed
depends_on: [] depends_on: []
scope: narrow scope: narrow
@@ -12,38 +12,38 @@ level: implementation
## Description ## Description
Review 002's C-2 (major): the pre-command RFC handshake and the Review 002's C-2 (major): the pre-command RFC handshake and the
association have **no timeout surface** — a silent channel holds a association have **no timeout surface**: a silent channel holds a
spawned task and reader state indefinitely (bounded memory, spawned task and reader state indefinitely (bounded memory,
unbounded time). The alkcall 10s establishment bound governs the unbounded time). The alkcall 10s establishment bound governs the
*open-op call*, not the in-stream conversation *open-op call*, not the in-stream conversation
(`src/producer.rs:183-191` parks on `accept_bi` → `accept_no_auth` (`src/producer.rs:183-191` parks on `accept_bi` → `accept_no_auth`
→ `read_command` with no timeout; the datagram stage's loop exits → `read_command` with no timeout; the datagram stage's loop exits
only on EOF/error/data). BIND's quiet wait got an explicit decision only on EOF/error/data). BIND's quiet wait got an explicit decision
(ADR-007 — teardown-governed, "FTP-length waits stay legal"); the (ADR-007: teardown-governed, "FTP-length waits stay legal"); the
handshake and the association lifetime got "inherited from the handshake and the association lifetime got "inherited from the
channel" — implicit, never decided. channel": implicit, never decided.
The mitigations are real (the 256-channel cap bounds the count, The mitigations are real (the 256-channel cap bounds the count,
memory per idle channel is bounded, teardown fires on channel memory per idle channel is bounded, teardown fires on channel
close — nothing leaks, nothing grows), but the aggregate is: any close; nothing leaks, nothing grows), but the aggregate is: any
identity with `socks5:open` can hold 256 parked tasks + 256 channel identity with `socks5:open` can hold 256 parked tasks + 256 channel
entries indefinitely *without sending a byte*. And batch 2's entries indefinitely *without sending a byte*. And batch 2's
providers (`local`, `composed`) are about to route *vanilla providers (`local`, `composed`) are about to route *vanilla
clients* through these same phases via the front doors, where the clients* through these same phases via the front doors, where the
peer is a kernel socket often outside alkcall's channel-cap regime peer is a kernel socket often outside alkcall's channel-cap regime
entirely — slowloris-shaped holds become the assembly layer's entirely, so slowloris-shaped holds become the assembly layer's
problem, and the protocol layer is where a timeout seam would live. problem, and the protocol layer is where a timeout seam would live.
**The deliverable is a decision, not code** — ADR-014 **The deliverable is a decision, not code**: ADR-014
(`docs/architecture/decisions/014-*.md`), landing *before* the (`docs/architecture/decisions/014-*.md`), landing *before* the
`local` doors exist. Decide between the review's two options: `local` doors exist. Decide between the review's two options:
1. **Idle timeout is the substrate's/assembly's job** — the 1. **Idle timeout is the substrate's/assembly's job**: the
protocol layer never times out (consistent with "no in-band protocol layer never times out (consistent with "no in-band
control vocabulary" and association-lifetime-equals-channel); control vocabulary" and association-lifetime-equals-channel);
record the DoS posture explicitly (the cap bounds it; doors record the DoS posture explicitly (the cap bounds it; doors
apply their own accept timeouts). Cheapest, defensible. apply their own accept timeouts). Cheapest, defensible.
2. **A producer-side idle bound** — an optional per-session 2. **A producer-side idle bound**: an optional per-session
inactivity timeout at the pump level (config on inactivity timeout at the pump level (config on
`Socks5ProducerBuilder`), enforced by racing the handshake/stage `Socks5ProducerBuilder`), enforced by racing the handshake/stage
reads with a sleep timer; a timeout = clean EOF-shaped teardown, reads with a sleep timer; a timeout = clean EOF-shaped teardown,
@@ -54,11 +54,11 @@ problem, and the protocol layer is where a timeout seam would live.
The ADR must state, whichever option wins: the BIND quiet wait's The ADR must state, whichever option wins: the BIND quiet wait's
existing decision (ADR-007) and how the new decision relates to it existing decision (ADR-007) and how the new decision relates to it
(same regime or different — and why the difference is principled); (same regime or different, and why the difference is principled);
the DoS posture in the doors' terms (what a `local` door's accept the DoS posture in the doors' terms (what a `local` door's accept
path must do if the protocol layer does not); and whether the path must do if the protocol layer does not); and whether the
decision is revisitable post-batch-2 or is itself a one-way door decision is revisitable post-batch-2 or is itself a one-way door
(it should not be — a timeout seam is additive — but say so). (it should not be, since a timeout seam is additive, but say so).
Also update `docs/architecture/open-questions.md` if C-2 was Also update `docs/architecture/open-questions.md` if C-2 was
tracked there, and the affected spec lines (wire.md §UDP tracked there, and the affected spec lines (wire.md §UDP
@@ -77,7 +77,7 @@ one.
- [x] The doors' DoS posture is recorded (what the assembly layer - [x] The doors' DoS posture is recorded (what the assembly layer
must do if the protocol layer does not) must do if the protocol layer does not)
- [x] The affected spec lines (wire.md, producer.md) carry the - [x] The affected spec lines (wire.md, producer.md) carry the
decided posture — no implicit residue decided posture; no implicit residue
- [x] The decision lands before any `local`/`composed` door work - [x] The decision lands before any `local`/`composed` door work
(the batch-2 tasks may depend on this one) (the batch-2 tasks may depend on this one)
- [x] `cargo doc --no-deps` clean if any doc-comment carries the - [x] `cargo doc --no-deps` clean if any doc-comment carries the
@@ -86,28 +86,28 @@ one.
## References ## References
- docs/reviews/002-protocol-core-implementation-review.md §C-2 (the - docs/reviews/002-protocol-core-implementation-review.md §C-2 (the
finding — the task's source of truth) finding, the task's source of truth)
- docs/architecture/decisions/007-bind-in-base.md (the quiet wait's - docs/architecture/decisions/007-bind-in-base.md (the quiet wait's
existing decision — the contrast case) existing decision, the contrast case)
- docs/architecture/decisions/008-provider-defaults.md, - docs/architecture/decisions/008-provider-defaults.md,
009-feature-gates.md (the batch-2 provider posture the decision 009-feature-gates.md (the batch-2 provider posture the decision
precedes) precedes)
- docs/architecture/wire.md §UDP ASSOCIATE (association lifetime = - docs/architecture/wire.md §UDP ASSOCIATE (association lifetime =
channel lifetime — the contract the decision qualifies) channel lifetime; the contract the decision qualifies)
- The POC findings (the stage's measured shapes — the - The POC findings (the stage's measured shapes, the
"sufficient for relay postures" lineage) "sufficient for relay postures" lineage)
## Notes ## Notes
> Filled by the implementation agent (2026-09-24): > Filled by the implementation agent (2026-09-24):
- **Option 1 won** — the protocol layer never times out; every phase - **Option 1 won**: the protocol layer never times out; every phase
(pre-command handshake, BIND quiet wait, association lifetime) is (pre-command handshake, BIND quiet wait, association lifetime) is
teardown-governed (EOF / garbage / error sentinels), never teardown-governed (EOF / garbage / error sentinels), never
idle-expired. No code changed; the posture is recorded. idle-expired. No code changed; the posture is recorded.
- **The sharpening argument** (why a handshake-only bound is - **The sharpening argument** (why a handshake-only bound is
incoherent): every post-reply phase *legally* parks indefinitely on incoherent): every post-reply phase *legally* parks indefinitely on
a silent peer — an idle CONNECT tunnel, a quiet association, a silent peer: an idle CONNECT tunnel, a quiet association,
BIND's quiet wait (ADR-007). The pre-command hold costs exactly BIND's quiet wait (ADR-007). The pre-command hold costs exactly
what those holds cost (one parked task + one channel entry); a what those holds cost (one parked task + one channel entry); a
handshake-only bound protects a sliver while the dominant hold handshake-only bound protects a sliver while the dominant hold
@@ -115,24 +115,24 @@ one.
timeout, which contradicts the landed contracts (association timeout, which contradicts the landed contracts (association
lifetime = channel lifetime; "FTP-length waits stay legal," lifetime = channel lifetime; "FTP-length waits stay legal,"
ADR-007). The unbounded-time aggregate is a property of every idle ADR-007). The unbounded-time aggregate is a property of every idle
session — bounded by the cap regime (ADR-040/041), not by a timer. session, bounded by the cap regime (ADR-040/041), not by a timer.
- **One upstream-fidelity correction to the review's sketch**: - **One upstream-fidelity correction to the review's sketch**:
upstream's connect-timeout path maps its *dial* timeout to upstream's connect-timeout path maps its *dial* timeout to
`ReplyError::ConnectionTimeout` → 0x06 (TTL expired), not 0x01 — `ReplyError::ConnectionTimeout` → 0x06 (TTL expired), not 0x01;
verified in the reference checkout (`util/stream.rs`) and the verified in the reference checkout (`util/stream.rs`) and the
forked table (`src/rfc1928/mod.rs`). Recorded in the ADR; relevant forked table (`src/rfc1928/mod.rs`). Recorded in the ADR; relevant
only if option 2 is ever built. only if option 2 is ever built.
- **open-questions.md needed no update** — C-2 was tracked as a - **open-questions.md needed no update**: C-2 was tracked as a
review finding (tasks/), not as an OQ; no OQ entry exists for it. review finding (tasks/), not as an OQ; no OQ entry exists for it.
- **The doors' obligations are recorded pre-batch-2** (the - **The doors' obligations are recorded pre-batch-2** (the
"inherits this bound knowingly" pattern, wire.md §UDP "inherits this bound knowingly" pattern, wire.md §UDP
ASSOCIATE's): an accept-stage idle bound (the slowloris sliver — ASSOCIATE's): an accept-stage idle bound (the slowloris sliver:
an accepted socket is not a channel, the cap regime does not bound an accepted socket is not a channel, the cap regime does not bound
it), per-credential/per-peer session caps (the cred-bearing it), per-credential/per-peer session caps (the cred-bearing
door's config table, ADR-005, is the knob), and accept-queue/ door's config table, ADR-005, is the knob), and accept-queue/
relay-task bounds. Same obligations for the producer-side local relay-task bounds. Same obligations for the producer-side local
backend's accept loop and a composed bind-first listener's front. backend's accept loop and a composed bind-first listener's front.
- **Revisitability**: not a one-way door — a producer-side idle seam - **Revisitability**: not a one-way door: a producer-side idle seam
is additive (builder config defaulting to `None` = this posture; is additive (builder config defaulting to `None` = this posture;
`tokio::time` already in the wasm-clean subset; timeout = EOF- `tokio::time` already in the wasm-clean subset; timeout = EOF-
shaped teardown). Revisit trigger recorded: a deployment with a shaped teardown). Revisit trigger recorded: a deployment with a
@@ -143,19 +143,19 @@ one.
ADR-014 landed ADR-014 landed
(`docs/architecture/decisions/014-idle-timeout-posture.md`, status (`docs/architecture/decisions/014-idle-timeout-posture.md`, status
accepted, 2026-09-24), deciding review 002 C-2: **option 1 — the accepted, 2026-09-24), deciding review 002 C-2: **option 1, the
protocol layer never times out**. The pre-command handshake, the protocol layer never times out**. The pre-command handshake, the
BIND quiet wait, and the association lifetime are all BIND quiet wait, and the association lifetime are all
teardown-governed; the BIND relationship is recorded as *same teardown-governed; the BIND relationship is recorded as *same
regime* (ADR-007 decided one phase's ends by designing BIND; ADR-014 regime* (ADR-007 decided one phase's ends by designing BIND; ADR-014
decides the rest by recording the inherited resolution — one decides the rest by recording the inherited resolution. One
principle: the protocol layer cannot distinguish malicious silence principle: the protocol layer cannot distinguish malicious silence
from a slow legitimate peer, so timeout policy belongs to the from a slow legitimate peer, so timeout policy belongs to the
deployment-facing layer). The DoS posture is explicit and layered deployment-facing layer). The DoS posture is explicit and layered
(ACL → per-connection cap → per-identity cap → door/dial policy — (ACL → per-connection cap → per-identity cap → door/dial policy;
the ADR-010 pattern), with the doors' accept-path obligations the ADR-010 pattern), with the doors' accept-path obligations
recorded before any batch-2 door exists. The decision is revisitable recorded before any batch-2 door exists. The decision is revisitable
additively (not a one-way door). Spec lines updated — wire.md §UDP additively (not a one-way door). Spec lines updated: wire.md §UDP
ASSOCIATE's association-lifetime bullet + the phase-model ASSOCIATE's association-lifetime bullet + the phase-model
"no in-band control vocabulary" bullet + the preamble's ADR list + "no in-band control vocabulary" bullet + the preamble's ADR list +
frontmatter; producer.md §The Open Handler's pump description + the frontmatter; producer.md §The Open Handler's pump description + the
@@ -1,6 +1,6 @@
--- ---
id: architecture/oq-sk2-06-wasm-adapter-story id: architecture/oq-sk2-06-wasm-adapter-story
name: Track — OQ-SK2-06 unblock condition (sandboxed-wasm-consumer adapter) name: Track for OQ-SK2-06 unblock condition (sandboxed-wasm-consumer adapter)
status: pending status: pending
depends_on: [] depends_on: []
scope: single scope: single
@@ -13,16 +13,16 @@ level: research
External-trigger tracker for OQ-SK2-06 (`docs/architecture/ External-trigger tracker for OQ-SK2-06 (`docs/architecture/
open-questions.md`): the sandboxed-wasm-consumer adapter story. This open-questions.md`): the sandboxed-wasm-consumer adapter story. This
task is NOT actionable work — it tracks whether the external task is NOT actionable work; it tracks whether the external
unblocking condition has arrived. Tagged `[external-trigger, unblocking condition has arrived. Tagged `[external-trigger,
deferred-oq]` per `docs/sdd_process.md` §Deferred OQs. deferred-oq]` per `docs/sdd_process.md` §Deferred OQs.
**Unblocking condition**: a concrete sandboxed-adapter use case **Unblocking condition**: a concrete sandboxed-adapter use case
exists — a TS/Python (or other) adapter embedding the alksocks exists: a TS/Python (or other) adapter embedding the alksocks
consumer session in a wasm runtime. consumer session in a wasm runtime.
**Trigger ownership (checked 2026-09-28)**: external by verified **Trigger ownership (checked 2026-09-28)**: external by verified
default — no planned suite-internal surface names a sandboxed default: no planned suite-internal surface names a sandboxed
alksocks consumer (see the OQ's "Trigger ownership" bullet for the alksocks consumer (see the OQ's "Trigger ownership" bullet for the
check). If a product surface later names one, convert this tracker check). If a product surface later names one, convert this tracker
to an owned dependency in the same round. to an owned dependency in the same round.
@@ -40,7 +40,7 @@ When the condition arrives:
## Verification ## Verification
N/A (tracking task — trivial risk, no code). N/A (tracking task; trivial risk, no code).
## Out of scope ## Out of scope
@@ -1,6 +1,6 @@
--- ---
id: architecture/oq-sk2-07-virtual-subnet-helper id: architecture/oq-sk2-07-virtual-subnet-helper
name: Track — OQ-SK2-07 unblock condition (virtual-subnet deployment) name: Track for OQ-SK2-07 unblock condition (virtual-subnet deployment)
status: pending status: pending
depends_on: [] depends_on: []
scope: single scope: single
@@ -13,13 +13,13 @@ level: research
External-trigger tracker for OQ-SK2-07 (`docs/architecture/ External-trigger tracker for OQ-SK2-07 (`docs/architecture/
open-questions.md`): the virtual-subnet translation-layer helper. open-questions.md`): the virtual-subnet translation-layer helper.
This task is NOT actionable work — it tracks whether the external This task is NOT actionable work; it tracks whether the external
unblocking condition has arrived. Tagged `[external-trigger, unblocking condition has arrived. Tagged `[external-trigger,
deferred-oq]` per `docs/sdd_process.md` §Deferred OQs. deferred-oq]` per `docs/sdd_process.md` §Deferred OQs.
**Unblocking condition**: a concrete deployment wants identity-scoped **Unblocking condition**: a concrete deployment wants identity-scoped
virtual subnets (proxied resources IP-addressable per identity) — virtual subnets (proxied resources IP-addressable per identity), the
the shape ADR-012 resolved as assembly-layer policy over the dial shape ADR-012 resolved as assembly-layer policy over the dial
callback. callback.
## Work ## Work
@@ -36,7 +36,7 @@ When the condition arrives:
## Verification ## Verification
N/A (tracking task — trivial risk, no code). N/A (tracking task; trivial risk, no code).
## Out of scope ## Out of scope
@@ -1,6 +1,6 @@
--- ---
id: implementation/assembly-integration-suite id: implementation/assembly-integration-suite
name: Assembly — the cross-cutting integration suite + docs sync name: "Assembly: the cross-cutting integration suite + docs sync"
status: completed status: completed
depends_on: [implementation/door-udp, implementation/local-backend-producer, implementation/composed-accept] depends_on: [implementation/door-udp, implementation/local-backend-producer, implementation/composed-accept]
scope: moderate scope: moderate
@@ -23,36 +23,36 @@ Deliverables:
- **The full `-D` story end to end**: a vanilla tun2proxy-shaped - **The full `-D` story end to end**: a vanilla tun2proxy-shaped
client → the cred-bearing TCP door → the channels-native client → the cred-bearing TCP door → the channels-native
session → the producer with `local` providers → real echo session → the producer with `local` providers → real echo
targets — CONNECT + UDP (the RFC-header'd datagram through the targets: CONNECT + UDP (the RFC-header'd datagram through the
UDP door) + BIND (the vanilla BIND conversation through the UDP door) + BIND (the vanilla BIND conversation through the
door) all through one door process against one producer door) all through one door process against one producer
registration (the one-registration-three-commands regression, registration (the one-registration-three-commands regression,
now through the doors). now through the doors).
- **The composed story end to end**: the producer with - **The composed story end to end**: the producer with
`composed::direct_egress` + `composed::bindfirst_accept` — `composed::direct_egress` + `composed::bindfirst_accept`:
CONNECT through the direct op, ASSOCIATE through the lazy CONNECT through the direct op, ASSOCIATE through the lazy
table, BIND through the bind-first listener (the ADR-008 table, BIND through the bind-first listener (the ADR-008
composed variant set, all three commands). composed variant set, all three commands).
- **Mixed assembly**: one producer registration serving - **Mixed assembly**: one producer registration serving
channels-native sessions AND door clients AND composed seams channels-native sessions AND door clients AND composed seams
concurrently (the producer/consumer-roles-not-sides posture — concurrently (the producer/consumer-roles-not-sides posture;
both shapes on one connection). both shapes on one connection).
- **Teardown matrix, assembly edition**: door-client disconnect - **Teardown matrix, assembly edition**: door-client disconnect
tears the association/channel down (no leaks — the channel tears the association/channel down (no leaks; the channel
count witnesses); the idle bounds fire; fresh sessions succeed count witnesses); the idle bounds fire; fresh sessions succeed
immediately after each teardown (no connection poisoning). immediately after each teardown (no connection poisoning).
- **The wasm gates re-verified**: default + `--features composed` - **The wasm gates re-verified**: default + `--features composed`
wasm checks green (the `local` feature is non-wasm by design — wasm checks green (the `local` feature is non-wasm by design;
verify it *fails* to build on wasm, the alktty posture, and verify it *fails* to build on wasm, the alktty posture, and
record the observed error in Notes). record the observed error in Notes).
2. **Docs sync** (the architecture-sync check): 2. **Docs sync** (the architecture-sync check):
- `docs/architecture/README.md` Current State + the doc statuses - `docs/architecture/README.md` Current State + the doc statuses
reflect the landed assembly layer (the "no `local`/`composed` reflect the landed assembly layer (the "no `local`/`composed`
code exists yet" lines updated). code exists yet" lines updated).
- `lib.rs` crate docs updated (the "not yet present" paragraph — - `lib.rs` crate docs updated (the "not yet present" paragraph;
the doors/providers are landed now). the doors/providers are landed now).
- Any spec drift the implementation surfaced reconciled in the - Any spec drift the implementation surfaced reconciled in the
same change — never silently diverged (the batch-1 discipline). same change, never silently diverged (the batch-1 discipline).
3. **Full verification sweep** (the batch gate's input): 3. **Full verification sweep** (the batch gate's input):
`cargo test`, `cargo test --all-features`, clippy `cargo test`, `cargo test --all-features`, clippy
`--all-targets --all-features -- -D warnings`, `cargo fmt `--all-targets --all-features -- -D warnings`, `cargo fmt
@@ -66,7 +66,7 @@ Deliverables:
commands commands
- [x] The composed variant set pinned end to end (all three commands) - [x] The composed variant set pinned end to end (all three commands)
- [x] Mixed assembly (doors + channels-native + composed) concurrent - [x] Mixed assembly (doors + channels-native + composed) concurrent
on one producer — pinned on one producer (pinned)
- [x] The assembly teardown matrix pinned (door disconnects, idle - [x] The assembly teardown matrix pinned (door disconnects, idle
bounds, fresh-session-after-teardown) bounds, fresh-session-after-teardown)
- [x] All four verification targets green (default, all-features, - [x] All four verification targets green (default, all-features,
@@ -76,7 +76,7 @@ Deliverables:
## References ## References
- docs/architecture/overview.md (the feature gates + module map — - docs/architecture/overview.md (the feature gates + module map;
the landed-shape check), consumer.md §The Front Doors, producer.md the landed-shape check), consumer.md §The Front Doors, producer.md
§Producer-Side Local Backend, §Wiring §Producer-Side Local Backend, §Wiring
- docs/architecture/decisions/005/007/008/009/014 - docs/architecture/decisions/005/007/008/009/014
@@ -91,24 +91,24 @@ Deliverables:
- **The spec drift the implementation surfaced (one real drift, - **The spec drift the implementation surfaced (one real drift,
reconciled in `src/composed/mod.rs`):** `ComposedEgress::next_reply` reconciled in `src/composed/mod.rs`):** `ComposedEgress::next_reply`
returned the association-end `Err` ("all tunnel relays ended") on a returned the association-end `Err` ("all tunnel relays ended") on a
*fresh* association too — the lazy table starts with `live_relays == *fresh* association too: the lazy table starts with `live_relays ==
0`, so the producer's datagram stage (which parks on `next_reply` 0`, so the producer's datagram stage (which parks on `next_reply`
when no datagram has arrived) ended every quiescent association at when no datagram has arrived) ended every quiescent association at
birth. The composed-egress suite drove the egress send-first and birth. The composed-egress suite drove the egress send-first and
never hit it; the assembly suite's `AssociateSession` (open → the never hit it; the assembly suite's `AssociateSession` (open → the
stage parks → the first datagram) did. Fix: an `ever_opened` flag — stage parks → the first datagram) did. Fix: an `ever_opened` flag;
the ended-relay `Err` fires only once at least one tunnel existed the ended-relay `Err` fires only once at least one tunnel existed
and every relay has ended; a never-opened table parks (the readiness and every relay has ended; a never-opened table parks (the readiness
contract's `Pending`, the channel branch governs). The landed spec contract's `Pending`, the channel branch governs). The landed spec
wording (producer.md: "`Pending` when nothing is ready") already wording (producer.md: "`Pending` when nothing is ready") already
stated the correct contract — the implementation was the divergent stated the correct contract; the implementation was the divergent
side. Regression-pinned in `tests/composed-egress.rs` side. Regression-pinned in `tests/composed-egress.rs`
(`fresh_association_parks_before_any_tunnel_is_opened`). (`fresh_association_parks_before_any_tunnel_is_opened`).
- **Two assembly-shape findings recorded in the tests (not code - **Two assembly-shape findings recorded in the tests (not code
changes — the landed contracts are correct):** changes; the landed contracts are correct):**
- The cred-bearing door's `TokenClient::wrap` takes the client's - The cred-bearing door's `TokenClient::wrap` takes the client's
channel-0 `CallConnection` once (the door-only contract, channel-0 `CallConnection` once (the door-only contract,
door-tcp's Notes) — the UDP door's association (and any other door-tcp's Notes); the UDP door's association (and any other
consumption shape) rides its own channels connection to the same consumption shape) rides its own channels connection to the same
producer registration. The suite pins the assembly story as "one producer registration. The suite pins the assembly story as "one
registration, many connections" (consumer.md reconciled). registration, many connections" (consumer.md reconciled).
@@ -116,7 +116,7 @@ Deliverables:
door shape (the cred-bearing TCP door's RFC half consumes the door shape (the cred-bearing TCP door's RFC half consumes the
greeting + one command; the producer-side `ProducerDoor`'s greeting + one command; the producer-side `ProducerDoor`'s
`serve_socks5_over` likewise serves one command per accepted `serve_socks5_over` likewise serves one command per accepted
socket — a second command's bytes ride the first session's data socket; a second command's bytes ride the first session's data
plane). The vanilla one-command-per-connection shape is pinned by plane). The vanilla one-command-per-connection shape is pinned by
the suite's structure (each command opens its own door the suite's structure (each command opens its own door
connection). connection).
@@ -125,12 +125,12 @@ Deliverables:
test bind, the accept queue + loop, the channel-set probe, the test bind, the accept queue + loop, the channel-set probe, the
identities) moved from `tests/composed-accept.rs` into identities) moved from `tests/composed-accept.rs` into
`tests/harness.rs` (single-sourced; `composed-accept.rs` consumes `tests/harness.rs` (single-sourced; `composed-accept.rs` consumes
the same shapes via the harness re-exports — its test bodies are the same shapes via the harness re-exports (its test bodies are
unchanged in behavior). The five-gate composed identity unchanged in behavior). The five-gate composed identity
(`composed_full_opener_identity` — `tunnel:open` + `tunnel:direct` (`composed_full_opener_identity`: `tunnel:open` + `tunnel:direct`
+ `tunnel:forwarded` + `socks5:open` + `socks5:bind`) is new. + `tunnel:forwarded` + `socks5:open` + `socks5:bind`) is new.
- **The wasm gate, observed**: `cargo check --features local --target - **The wasm gate, observed**: `cargo check --features local --target
wasm32-unknown-unknown` fails as designed (the alktty posture) — wasm32-unknown-unknown` fails as designed (the alktty posture);
the first error is `error: This wasm target is unsupported by mio. the first error is `error: This wasm target is unsupported by mio.
If using Tokio, disable the net feature.` (mio v1.2.3, via If using Tokio, disable the net feature.` (mio v1.2.3, via
`tokio/net` which `local` enables), followed by the mio `sys` `tokio/net` which `local` enables), followed by the mio `sys`
@@ -150,14 +150,14 @@ Deliverables:
> Filled on completion (2026-09-25). > Filled on completion (2026-09-25).
Landed the batch-2 closing task: `tests/assembly-integration.rs` — Landed the batch-2 closing task: `tests/assembly-integration.rs`,
seven cross-cutting integration tests pinning the whole assembly layer seven cross-cutting integration tests pinning the whole assembly layer
end to end (feature `local` + `composed`): end to end (feature `local` + `composed`):
1. **The full `-D` story** (`full_dash_d_story_...`): a vanilla 1. **The full `-D` story** (`full_dash_d_story_...`): a vanilla
tun2proxy-shaped client through the cred-bearing RFC 1929 TCP door tun2proxy-shaped client through the cred-bearing RFC 1929 TCP door
+ the UDP door against one producer registration with the `local` + the UDP door against one producer registration with the `local`
providers and real echo targets — CONNECT + BIND (the app server providers and real echo targets: CONNECT + BIND (the app server
dials the advertised address; reply#2 carries the real peer) + dials the advertised address; reply#2 carries the real peer) +
ASSOCIATE (the RFC-header'd datagram through the relay socket) ASSOCIATE (the RFC-header'd datagram through the relay socket)
live simultaneously, each exercised mid-life of the others, the live simultaneously, each exercised mid-life of the others, the
@@ -165,9 +165,9 @@ end to end (feature `local` + `composed`):
sessions succeeding after (no connection poisoning). sessions succeeding after (no connection poisoning).
2. **The composed story** (`composed_story_...`): the producer with 2. **The composed story** (`composed_story_...`): the producer with
`composed::direct_egress` + `composed::bindfirst_accept` + a real `composed::direct_egress` + `composed::bindfirst_accept` + a real
TCP dial — CONNECT, ASSOCIATE (the lazy table; the direct-op dial TCP dial: CONNECT, ASSOCIATE (the lazy table; the direct-op dial
count witnesses the lazy open), BIND (the real `bound` + the real count witnesses the lazy open), BIND (the real `bound` + the real
`peer` + the pass-through plane) — all three commands, all live. `peer` + the pass-through plane); all three commands, all live.
3. **Mixed assembly, twice**: channels-native sessions + door clients 3. **Mixed assembly, twice**: channels-native sessions + door clients
concurrent on one registration (the doors on their own concurrent on one registration (the doors on their own
connections), and composed seams beside channels-native sessions connections), and composed seams beside channels-native sessions
@@ -184,12 +184,12 @@ end to end (feature `local` + `composed`):
NoAuth raw-pipe door resolves at the producer's seam (ADR-012). NoAuth raw-pipe door resolves at the producer's seam (ADR-012).
The reconciled drift: `ComposedEgress`'s never-opened-table association The reconciled drift: `ComposedEgress`'s never-opened-table association
end (fixed in `src/composed/mod.rs` — the `ever_opened` guard; end (fixed in `src/composed/mod.rs`, the `ever_opened` guard;
regression-pinned in `tests/composed-egress.rs`). Docs synced: regression-pinned in `tests/composed-egress.rs`). Docs synced:
`README.md` Current State (the assembly layer landed; both suites `README.md` Current State (the assembly layer landed; both suites
named), `lib.rs` crate docs (the assembly-layer paragraph + both named), `lib.rs` crate docs (the assembly-layer paragraph + both
suites named), `consumer.md` §Door variants (the `TokenClient` suites named), `consumer.md` §Door variants (the `TokenClient`
door-only contract + one-command-per-connection posture reconciled — door-only contract + one-command-per-connection posture reconciled,
the drift the suite surfaced, recorded in Notes). Harness: the the drift the suite surfaced, recorded in Notes). Harness: the
composed-accept machinery single-sourced into `tests/harness.rs` (the composed-accept machinery single-sourced into `tests/harness.rs` (the
N-5 discipline). N-5 discipline).
@@ -200,4 +200,4 @@ warnings` + `cargo clippy --all-targets --all-features -- -D warnings`
clean, `cargo fmt --check` clean, `cargo doc --no-deps` clean (0 clean, `cargo fmt --check` clean, `cargo doc --no-deps` clean (0
warnings), both wasm checks green (default + `--features composed`, warnings), both wasm checks green (default + `--features composed`,
check + clippy), and the `local`-on-wasm build failure observed + check + clippy), and the `local`-on-wasm build failure observed +
recorded (the alktty posture — mio's unsupported-wasm error). recorded (the alktty posture: mio's unsupported-wasm error).
+30 -29
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/composed-accept id: implementation/composed-accept
name: Composed — the bind-first accept (the listener on a listen resource) name: "Composed: the bind-first accept (the listener on a listen resource)"
status: completed status: completed
depends_on: [implementation/composed-egress] depends_on: [implementation/composed-egress]
scope: moderate scope: moderate
@@ -14,7 +14,7 @@ level: implementation
The `composed` feature's accept provider (ADR-008's composed The `composed` feature's accept provider (ADR-008's composed
variants; ADR-007 variant 2): the BIND accept whose listener lives on variants; ADR-007 variant 2): the BIND accept whose listener lives on
an alktunnels 0.2.0 bind-first listen resource an alktunnels 0.2.0 bind-first listen resource
(`register_tunnel_bindfirst_openable` + `BindFirstInjections`) — (`register_tunnel_bindfirst_openable` + `BindFirstInjections`),
per-listener ACL for free, hub-relayable. **Vanilla-fidelity with no per-listener ACL for free, hub-relayable. **Vanilla-fidelity with no
stand-ins since the graduation** (ADR-007): reply#1 advertises the stand-ins since the graduation** (ADR-007): reply#1 advertises the
real OS-chosen `bound` (the open-op reply's `bound` field, alkcall real OS-chosen `bound` (the open-op reply's `bound` field, alkcall
@@ -23,15 +23,15 @@ op's `peer` param).
Deliverables: Deliverables:
1. **`src/composed/` additions** (feature `composed`) — the composed 1. **`src/composed/` additions** (feature `composed`): the composed
accept, named per producer.md §Wiring: accept, named per producer.md §Wiring:
- `bindfirst_accept(...)` → `BindAcceptFactory`: the POC #5 - `bindfirst_accept(...)` → `BindAcceptFactory`: the POC #5
`composed_bind_accept` shape **updated to the graduation** — `composed_bind_accept` shape **updated to the graduation**:
the POC's pinned workaround (reply#1 first, the tunnel open the POC's pinned workaround (reply#1 first, the tunnel open
rides the accept future; the factory carries an expected rides the accept future; the factory carries an expected
`bind_addr`) is **retired** (ADR-007 §Consequences). The landed `bind_addr`) is **retired** (ADR-007 §Consequences). The landed
shape: the factory opens the bind-first listen channel shape: the factory opens the bind-first listen channel
(`TunnelListener::open` — the reply projection surfaces (`TunnelListener::open`; the reply projection surfaces
`bound`), reply#1 advertises the real `bound`, and the accept `bound`), reply#1 advertises the real `bound`, and the accept
future correlates the forwarded sessions (the `listen` token → future correlates the forwarded sessions (the `listen` token →
the adopted forwarded sessions the assembly correlates; the the adopted forwarded sessions the assembly correlates; the
@@ -45,7 +45,7 @@ Deliverables:
ordinary adopted sessions on the manager; the listener does not ordinary adopted sessions on the manager; the listener does not
own the receipt queue"). The provider owns: the own the receipt queue"). The provider owns: the
`register_tunnel_forwarded_openable` serving side (the opener `register_tunnel_forwarded_openable` serving side (the opener
half — the SOCKS5 producer's connection must serve the half: the SOCKS5 producer's connection must serve the
forwarded op), the token→waiter map, and the per-forwarded forwarded op), the token→waiter map, and the per-forwarded
pump. Design the wait shape against the landed alkcall 0.8.0 pump. Design the wait shape against the landed alkcall 0.8.0
adopt surface (`manager.adopt_channel`) and record it in Notes. adopt surface (`manager.adopt_channel`) and record it in Notes.
@@ -53,8 +53,8 @@ Deliverables:
accepted halves must be `Box<dyn AsyncRead + Send + Sync + accepted halves must be `Box<dyn AsyncRead + Send + Sync +
Unpin>` (`BindTargetHandle`'s payloads); alktunnels' `Taken Unpin>` (`BindTargetHandle`'s payloads); alktunnels' `Taken
Halves` yields `Send + Unpin` only. The owner-wrapper rides Halves` yields `Send + Unpin` only. The owner-wrapper rides
**inside the handle payloads** (egress.rs's documented posture) **inside the handle payloads** (egress.rs's documented posture):
— the wrapper owns the adopted channel session so dropping the the wrapper owns the adopted channel session so dropping the
handle drops everything (the teardown-at-birth discipline, handle drops everything (the teardown-at-birth discipline,
compiler-enforced). This is the load-bearing wrapper: design it compiler-enforced). This is the load-bearing wrapper: design it
once (with composed-egress's needs), pin it with a test. once (with composed-egress's needs), pin it with a test.
@@ -64,15 +64,15 @@ Deliverables:
variant's. variant's.
- **The establishment bound covers only the open-op round trip** - **The establishment bound covers only the open-op round trip**
(ADR-007): the accept wait lives in the teardown-governed pump (ADR-007): the accept wait lives in the teardown-governed pump
phase — the quiet wait's `select!` drop of the accept future IS phase: the quiet wait's `select!` drop of the accept future IS
the client-gone teardown; the listener (inside the future) the client-gone teardown; the listener (inside the future)
closes with it. The forwarded-op correlation must survive a closes with it. The forwarded-op correlation must survive a
client-gone drop without leaking the listener (the accept client-gone drop without leaking the listener (the accept
future owns it end-to-end — the seam contract). future owns it end-to-end, which is the seam contract).
- Module doc: names the ADRs (007/008), the graduation's - Module doc: names the ADRs (007/008), the graduation's
vanilla-fidelity win, the correlation shape, the owner-wrapper. vanilla-fidelity win, the correlation shape, the owner-wrapper.
2. **Tests** (`tests/composed-accept.rs`): the full composed BIND 2. **Tests** (`tests/composed-accept.rs`): the full composed BIND
story over channels — a bind-first tunnel producer story over channels: a bind-first tunnel producer
(`register_tunnel_bindfirst_openable` + injections), the SOCKS5 (`register_tunnel_bindfirst_openable` + injections), the SOCKS5
producer with `composed::bindfirst_accept(...)`, a consumer BIND producer with `composed::bindfirst_accept(...)`, a consumer BIND
session: reply#1 carries the real `bound`, an app server dials session: reply#1 carries the real `bound`, an app server dials
@@ -89,14 +89,14 @@ Deliverables:
- [ ] `cargo clippy --all-targets --all-features -- -D warnings` - [ ] `cargo clippy --all-targets --all-features -- -D warnings`
clean; `cargo fmt --check` clean clean; `cargo fmt --check` clean
- [ ] The graduation's shape lands (reply#1 = the real `bound`; - [ ] The graduation's shape lands (reply#1 = the real `bound`;
reply#2 = the forwarded op's real `peer`) — no expected-addr reply#2 = the forwarded op's real `peer`, no expected-addr
stand-in — pinned by test stand-in), pinned by test
- [ ] The owner-wrapper rides inside the handle payloads (Sync - [ ] The owner-wrapper rides inside the handle payloads (Sync
halves; teardown structural) — pinned by test halves; teardown structural), pinned by test
- [ ] The client-gone teardown releases the listener (the accept - [ ] The client-gone teardown releases the listener (the accept
future owns it end-to-end) — pinned by drop-witness test future owns it end-to-end), pinned by drop-witness test
- [ ] The forwarded-op serving side + correlation land; per-accept - [ ] The forwarded-op serving side + correlation land; per-accept
isolation — pinned by tests isolation, pinned by tests
## References ## References
@@ -106,10 +106,10 @@ Deliverables:
- docs/architecture/producer.md §Wiring - docs/architecture/producer.md §Wiring
(`composed::bindfirst_accept`), §The Three Seams (`composed::bindfirst_accept`), §The Three Seams
- POC reference: `/workspace/alksocks-bind-poc/src/accept.rs` - POC reference: `/workspace/alksocks-bind-poc/src/accept.rs`
(`composed_bind_accept` — the superseded-in-shape workaround, the (`composed_bind_accept`, the superseded-in-shape workaround, the
owner-wrapper's first witness) owner-wrapper's first witness)
- Landed surfaces: `src/egress.rs` (`BindAccept`/`BindTargetHandle` - Landed surfaces: `src/egress.rs` (`BindAccept`/`BindTargetHandle`,
— the contracts), alktunnels 0.2.0 (`register_tunnel_bindfirst_ the contracts), alktunnels 0.2.0 (`register_tunnel_bindfirst_
openable`, `BindFirstInjections`, `TunnelListener`, openable`, `BindFirstInjections`, `TunnelListener`,
`register_tunnel_forwarded_openable`, `ForwardedHandler`) `register_tunnel_forwarded_openable`, `ForwardedHandler`)
@@ -125,26 +125,27 @@ Deliverables:
removes the entry on the arriving forwarded open and delivers removes the entry on the arriving forwarded open and delivers
`ForwardedSession { far duplex halves, peer }`. A dropped receiver `ForwardedSession { far duplex halves, peer }`. A dropped receiver
(client-gone) or a miss makes the handler drop the forwarded (client-gone) or a miss makes the handler drop the forwarded
channel stream — teardown-at-birth, R-02 (the inline `pump_bidi` channel stream: teardown-at-birth, R-02 (the inline `pump_bidi`
await in `register_openable`'s wrapper then reaps the entry). await in `register_openable`'s wrapper then reaps the entry).
- **The Sync-bound wrinkle**: alktunnels' halves are `Send + Unpin` - **The Sync-bound wrinkle**: alktunnels' halves are `Send + Unpin`
only; the landed `BindTargetHandle` payloads carry `+ Sync`. Two only; the landed `BindTargetHandle` payloads carry `+ Sync`. Two
adapters pin the no-unsafe Sync proof (`Mutex<T: Send>` is `Sync`; adapters pin the no-unsafe Sync proof (`Mutex<T: Send>` is `Sync`;
contention never occurs — one pump task at a time): `SyncHalves` contention never occurs, since only one pump task runs at a time):
`SyncHalves`
(read half) and `OwnedWrite` (write half + the owner inside). (read half) and `OwnedWrite` (write half + the owner inside).
- **The owner-wrapper rides inside the payloads** - **The owner-wrapper rides inside the payloads**
(`ForwardedOwner`): the write payload owns the `TunnelListener` — (`ForwardedOwner`): the write payload owns the `TunnelListener`;
dropping the handle drops the listener → the listen channel EOFs → dropping the handle drops the listener → the listen channel EOFs →
the far teardown cascades (teardown structural, the far teardown cascades (teardown structural,
compiler-enforced). The edition-2021 disjoint-capture trap (an compiler-enforced). The edition-2021 disjoint-capture trap (an
earlier draft captured `listener.channel_id`, dropping the earlier draft captured `listener.channel_id`, dropping the
listener at the factory's return — EOF at birth) is why the whole listener at the factory's return, that is EOF at birth) is why the whole
struct is rebound into the accept future. struct is rebound into the accept future.
- **The three-party bridge pump**: the forwarded handler bridges the - **The three-party bridge pump**: the forwarded handler bridges the
forwarded `BiStream` to a local duplex pair whose far end travels forwarded `BiStream` to a local duplex pair whose far end travels
into the accept future's payloads. The bridge completes on EITHER into the accept future's payloads. The bridge completes on EITHER
direction's EOF (a `pump_bidi`-then-drop, not a per-direction direction's EOF (a `pump_bidi`-then-drop, not a per-direction
shutdown cascade — a three-party half-close cycle would deadlock: shutdown cascade; a three-party half-close cycle would deadlock:
each pump's completion-shutdown needs the next hop's EOF, and the each pump's completion-shutdown needs the next hop's EOF, and the
chain closes on itself). Dropping everything EOFs the SOCKS5 chain closes on itself). Dropping everything EOFs the SOCKS5
producer-side pump, ends the per-accept pump, and tears the producer-side pump, ends the per-accept pump, and tears the
@@ -154,7 +155,7 @@ Deliverables:
forwarded channel id on the producer-side manager; that entry's forwarded channel id on the producer-side manager; that entry's
reap rides the per-adopter contract + transport EOF, NOT the reap rides the per-adopter contract + transport EOF, NOT the
listener's teardown (alktunnels ADR-005; upstream's own bind-first listener's teardown (alktunnels ADR-005; upstream's own bind-first
suites make the same concession — `producer_bindfirst.rs` suites make the same concession: `producer_bindfirst.rs`
`no_stray_channels` documents the listen channel, and the `no_stray_channels` documents the listen channel, and the
forwarded entries are adopted sessions owned by their adopter). forwarded entries are adopted sessions owned by their adopter).
`assert_only_channels` therefore treats odd producer-side ids as `assert_only_channels` therefore treats odd producer-side ids as
@@ -162,8 +163,8 @@ Deliverables:
- **Forwarded-open failure semantics**: a FORBIDDEN forwarded open is - **Forwarded-open failure semantics**: a FORBIDDEN forwarded open is
per-accept isolated AT THE TUNNEL LAYER (the producer's accept loop per-accept isolated AT THE TUNNEL LAYER (the producer's accept loop
drops that connection and keeps serving; the alktunnels drops that connection and keeps serving; the alktunnels
producer-suite pin). The SOCKS5 accept future stays parked — the producer-suite pin). The SOCKS5 accept future stays parked; the
SOCKS5 producer never learns of the tunnel-layer failure — so no SOCKS5 producer never learns of the tunnel-layer failure, so no
reply#2 arrives; the quiet wait is unaffected. Pinned by test. reply#2 arrives; the quiet wait is unaffected. Pinned by test.
## Summary ## Summary
@@ -178,7 +179,7 @@ bookkeeping; the provider owns the forwarded-op serving half, the token→waiter
map, and the per-forwarded middle pump (either-EOF completion shape). The map, and the per-forwarded middle pump (either-EOF completion shape). The
Sync-bound wrinkle is met by `SyncHalves`/`OwnedWrite` (mutex Sync-proof, no Sync-bound wrinkle is met by `SyncHalves`/`OwnedWrite` (mutex Sync-proof, no
unsafe) with the owner (`ForwardedOwner`, the `TunnelListener`) riding inside unsafe) with the owner (`ForwardedOwner`, the `TunnelListener`) riding inside
the write payload — dropping the handle drops everything (teardown structural, the write payload: dropping the handle drops everything (teardown structural,
compiler-enforced). `tests/composed-accept.rs` pins: the full story (real compiler-enforced). `tests/composed-accept.rs` pins: the full story (real
`bound`, real `peer`, raw pass-through round trip), the client-gone teardown `bound`, real `peer`, raw pass-through round trip), the client-gone teardown
(drop-witness: the accept future owns the listener end-to-end; no leaked (drop-witness: the accept future owns the listener end-to-end; no leaked
@@ -187,4 +188,4 @@ isolation of a FORBIDDEN forwarded open, and the late-forwarded correlation
miss dropping the channel at birth. Verified: `cargo test`, `cargo test miss dropping the channel at birth. Verified: `cargo test`, `cargo test
--all-features`, `cargo check/clippy --target wasm32-unknown-unknown` (hard --all-features`, `cargo check/clippy --target wasm32-unknown-unknown` (hard
gate), `cargo clippy --all-targets --all-features -- -D warnings`, gate), `cargo clippy --all-targets --all-features -- -D warnings`,
`cargo fmt --check` — all clean. `cargo fmt --check`: all clean.
+33 -33
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/composed-egress id: implementation/composed-egress
name: Composed — the direct-op egress (lazy destination→tunnel table) name: "Composed: the direct-op egress (lazy destination→tunnel table)"
status: completed status: completed
depends_on: [] depends_on: []
scope: moderate scope: moderate
@@ -14,88 +14,88 @@ level: implementation
The `composed` feature's egress provider (ADR-008's composed The `composed` feature's egress provider (ADR-008's composed
variants): the ASSOCIATE egress that dials each destination through variants): the ASSOCIATE egress that dials each destination through
an alktunnels 0.2.0 direct-op channel (`TunnelSession::open_direct`, an alktunnels 0.2.0 direct-op channel (`TunnelSession::open_direct`,
`Substrate::Udp`) — per-target ACL for free (each destination rides `Substrate::Udp`): per-target ACL for free (each destination rides
its own `tunnel:direct`-governed channel), the lazy its own `tunnel:direct`-governed channel), the lazy
destination→tunnel table LRU-evicted at a cap (POC #2's destination→tunnel table LRU-evicted at a cap (POC #2's
`ComposedEgress` shape). `ComposedEgress` shape).
Deliverables: Deliverables:
1. **`Cargo.toml`** — `composed = ["dep:alktunnels"]` (the only 1. **`Cargo.toml`**: `composed = ["dep:alktunnels"]` (the only
feature that adds the alktunnels dependency, ADR-009); alktunnels feature that adds the alktunnels dependency, ADR-009); alktunnels
pinned `"0.2.0"` (the sibling pin posture — convention 11's pinned `"0.2.0"` (the sibling pin posture; convention 11's
deliberate-bump rule applies). Verify the composed graph stays deliberate-bump rule applies). Verify the composed graph stays
wasm-clean (alktunnels' default crate is wasm-clean; the composed wasm-clean (alktunnels' default crate is wasm-clean; the composed
providers use only its ungated channels surface — the wasm check providers use only its ungated channels surface, and the wasm check
with `--features composed` is a hard gate). with `--features composed` is a hard gate).
2. **`src/composed/mod.rs`** (feature `composed`) — the composed 2. **`src/composed/mod.rs`** (feature `composed`): the composed
providers, named per producer.md §Wiring: providers, named per producer.md §Wiring:
- `direct_egress(...)` → `EgressFactory`: the POC #2 - `direct_egress(...)` → `EgressFactory`: the POC #2
`ComposedEgress` shape ported to the landed seam types — a lazy `ComposedEgress` shape ported to the landed seam types: a lazy
destination→tunnel table: each new destination opens one destination→tunnel table: each new destination opens one
alktunnels UDP-tunnel channel (`open_direct`, `Substrate::Udp`), alktunnels UDP-tunnel channel (`open_direct`, `Substrate::Udp`),
a relay task per tunnel drains inbound datagrams into a shared a relay task per tunnel drains inbound datagrams into a shared
queue, the write half stays in the table (LRU-capped; dropping queue, the write half stays in the table (LRU-capped; dropping
a write half EOFs the tunnel producer-side; the session's Drop a write half EOFs the tunnel producer-side; the session's Drop
reaps the channel entry — alktunnels ADR-005). reaps the channel entry (alktunnels ADR-005).
- **The Sync-bound wrinkle (the batch-1 review's seam contract vs - **The Sync-bound wrinkle (the batch-1 review's seam contract vs
alktunnels' surface)**: the landed `TargetHandle`/`BindTarget alktunnels' surface)**: the landed `TargetHandle`/`BindTarget
Handle` halves are `Box<dyn AsyncRead + Send + Sync + Unpin>`, Handle` halves are `Box<dyn AsyncRead + Send + Sync + Unpin>`,
but alktunnels' `TakenHalves` yields `Send + Unpin` only. The but alktunnels' `TakenHalves` yields `Send + Unpin` only. The
composed providers need an **owner-wrapper inside the handle composed providers need an **owner-wrapper inside the handle
payloads** (egress.rs's documented posture: "keep-alive state a payloads** (egress.rs's documented posture: "keep-alive state a
substrate needs rides inside the halves — a provider-side owner substrate needs rides inside the halves; store a provider-side owner
wrapper"). For the egress the wrapper is the table entry's wrapper"). For the egress the wrapper is the table entry's
session (the egress owns it — not a handle payload); for the session (the egress owns it, not a handle payload); for the
composed *accept* (the sibling task) the wrapper is load-bearing composed *accept* (the sibling task) the wrapper is load-bearing,
— design it here, share it. so design it here and share it.
- **The readiness contract** (load-bearing): `next_reply` parks on - **The readiness contract** (load-bearing): `next_reply` parks on
the shared inbound queue (`recv().await` — Pending until a the shared inbound queue (`recv().await`, Pending until a
relay task delivers); a closed queue (all relays ended) returns relay task delivers); a closed queue (all relays ended) returns
`Err` — never a hot `Ok(None)` (the POC's closed-queue arm; the `Err`, never a hot `Ok(None)` (the POC's closed-queue arm; the
stage's zero-yield defense, review 002 R-1, is the backstop, stage's zero-yield defense, review 002 R-1, is the backstop,
not the design). Replies carry *inner* bytes (`addr + data` — not the design). Replies carry *inner* bytes (`addr + data`;
the reply address is the entry's recorded destination; the reply address is the entry's recorded destination;
framing-once). framing-once).
- **Per-datagram `send` is awaited inline** (the stage - **Per-datagram `send` is awaited inline** (the stage
interleaves sends with reply drains) — a slow egress applies interleaves sends with reply drains), so a slow egress applies
backpressure to the association, not to other channels backpressure to the association, not to other channels
(wire.md's note: "the batch-2 composed-egress author inherits (wire.md's note: "the batch-2 composed-egress author inherits
this bound knowingly"). The table's open cost rides the send this bound knowingly"). The table's open cost rides the send
path (lazy open on first datagram — the POC's measured ~0.8 ms). path (lazy open on first datagram; the POC's measured ~0.8 ms).
- The resource/target mapping: `open_direct` takes a - The resource/target mapping: `open_direct` takes a
`SubstrateAddr` — the destination's `TargetAddr` renders to it `SubstrateAddr`: the destination's `TargetAddr` renders to it
(IP forms direct; domain form passes through unresolved — (IP forms direct; domain form passes through unresolved,
producer-side resolution, the ssh `-D` semantic; ADR-012). producer-side resolution, the ssh `-D` semantic; ADR-012).
Constructor config carries the LRU cap (the 256-channel default Constructor config carries the LRU cap (the 256-channel default
cap bounds fan-out — ADR-010's posture; the cap is config, not cap bounds fan-out, ADR-010's posture; the cap is config, not
wire). wire).
- Module doc: names the ADRs (008/009), the per-target-ACL - Module doc: names the ADRs (008/009), the per-target-ACL
benefit, the readiness contract, the framing-once invariant, benefit, the readiness contract, the framing-once invariant,
the backpressure bound. the backpressure bound.
3. **Tests** (`tests/composed-egress.rs`): the POC #2 shape end to 3. **Tests** (`tests/composed-egress.rs`): the POC #2 shape end to
end over channels — a tunnel-producing peer (the alktunnels end over channels: a tunnel-producing peer (the alktunnels
registry shape, `register_tunnel_direct_openable`), the composed registry shape, `register_tunnel_direct_openable`), the composed
egress against it, datagram round-trips through the tunnel egress against it, datagram round-trips through the tunnel
(multiple destinations → multiple tunnels; the LRU eviction at (multiple destinations → multiple tunnels; the LRU eviction at
cap pinned by test); the readiness contract re-pinned (park-then- cap pinned by test); the readiness contract re-pinned (park-then-
datagram, no spin); the closed-queue → association-end shape; datagram, no spin); the closed-queue → association-end shape;
framing-once (no double prefix — the POC #2 bug class). framing-once (no double prefix, the POC #2 bug class).
## Acceptance Criteria ## Acceptance Criteria
- [x] `cargo test` passes; `cargo test --all-features` passes - [x] `cargo test` passes; `cargo test --all-features` passes
- [x] `cargo check --features composed --target wasm32-unknown- - [x] `cargo check --features composed --target wasm32-unknown-
unknown` clean — the composed graph is wasm-clean (hard gate) unknown` clean; the composed graph is wasm-clean (hard gate)
- [x] `cargo clippy --all-targets --all-features -- -D warnings` - [x] `cargo clippy --all-targets --all-features -- -D warnings`
clean; `cargo fmt --check` clean clean; `cargo fmt --check` clean
- [x] `alktunnels` pinned `"0.2.0"`; the dep rides `composed` alone - [x] `alktunnels` pinned `"0.2.0"`; the dep rides `composed` alone
(default graph unchanged — `cargo tree` spot-check) (default graph unchanged, per the `cargo tree` spot-check)
- [x] The lazy table + LRU eviction land per the POC shape — pinned - [x] The lazy table + LRU eviction land per the POC shape (pinned
by test by test)
- [x] The readiness contract honored (real async park; closed queue - [x] The readiness contract honored (real async park; closed queue
→ `Err`, never a hot `Ok(None)`) — pinned by test → `Err`, never a hot `Ok(None)`), pinned by test
- [x] Framing-once (replies are inner bytes; no double prefix) — - [x] Framing-once (replies are inner bytes; no double prefix),
pinned by test pinned by test
- [x] Per-datagram send backpressure documented on the constructor - [x] Per-datagram send backpressure documented on the constructor
@@ -107,7 +107,7 @@ Deliverables:
- docs/architecture/producer.md §Wiring (`composed::direct_egress`), - docs/architecture/producer.md §Wiring (`composed::direct_egress`),
§The Three Seams §The Three Seams
- POC reference: `/workspace/alksocks-udp-poc/src/egress.rs` - POC reference: `/workspace/alksocks-udp-poc/src/egress.rs`
(`ComposedEgress` — the table, the relay tasks, the closed-queue (`ComposedEgress`: the table, the relay tasks, the closed-queue
posture) posture)
- Landed surfaces: `src/egress.rs` (the seam contracts + the - Landed surfaces: `src/egress.rs` (the seam contracts + the
owner-wrapper posture), alktunnels 0.2.0 owner-wrapper posture), alktunnels 0.2.0
@@ -119,14 +119,14 @@ Deliverables:
> Landed as `src/composed/mod.rs` (feature `composed`) + > Landed as `src/composed/mod.rs` (feature `composed`) +
> `tests/composed-egress.rs`, with a `wire_tunnel_direct` topology > `tests/composed-egress.rs`, with a `wire_tunnel_direct` topology
> added to `tests/harness.rs` (the consumer-side topology with > added to `tests/harness.rs` (the consumer-side topology with
> alktunnels' direct op on the producer's serving registry — the > alktunnels' direct op on the producer's serving registry; the
> composed egress's `ChannelClient` is the direct-op caller). > composed egress's `ChannelClient` is the direct-op caller).
> >
> Two deviations from the POC shape, both deliberate: > Two deviations from the POC shape, both deliberate:
> >
> - **The closed-queue condition is a live-relay count, not a queue > - **The closed-queue condition is a live-relay count, not a queue
> close.** The egress holds its own factory `Sender`, so the shared > close.** The egress holds its own factory `Sender`, so the shared
> `mpsc` queue can never fully disconnect — `recv()` would never > `mpsc` queue can never fully disconnect: `recv()` would never
> return `None` and the POC's closed-queue arm was unreachable > return `None` and the POC's closed-queue arm was unreachable
> through this surface. Relays instead emit an `Ended` marker on > through this surface. Relays instead emit an `Ended` marker on
> their tunnel's EOF; `next_reply` counts them (`live_relays`) and > their tunnel's EOF; `next_reply` counts them (`live_relays`) and
@@ -134,7 +134,7 @@ Deliverables:
> the queue is drained. The readiness contract is unchanged (the > the queue is drained. The readiness contract is unchanged (the
> wait is a real async park; a drained-but-live relay set parks). > wait is a real async park; a drained-but-live relay set parks).
> - **The tunnel codec is alktunnels' `wire::DatagramReader` > - **The tunnel codec is alktunnels' `wire::DatagramReader`
> (`[len]` + payload), not this crate's RFC-framed one** — the POC > (`[len]` + payload), not this crate's RFC-framed one**; the POC
> fed the tunnel read half into the SOCKS wire reader (a bug class > fed the tunnel read half into the SOCKS wire reader (a bug class
> the round-trip test caught during bring-up); the composed egress > the round-trip test caught during bring-up); the composed egress
> frames the tunnel wire once with the tunnel codec and the RFC > frames the tunnel wire once with the tunnel codec and the RFC
@@ -154,7 +154,7 @@ Deliverables:
> The composed egress landed: `composed = ["dep:alktunnels"]` > The composed egress landed: `composed = ["dep:alktunnels"]`
> (alktunnels pinned "0.2.0"; dep rides `composed` alone), > (alktunnels pinned "0.2.0"; dep rides `composed` alone),
> `src/composed/mod.rs` — `direct_egress` (the `EgressFactory`), the > `src/composed/mod.rs` (`direct_egress`, the `EgressFactory`), the
> lazy destination→tunnel table with LRU eviction at cap (`DEFAULT_ > lazy destination→tunnel table with LRU eviction at cap (`DEFAULT_
> MAX_TUNNELS` = 256), per-tunnel relay tasks draining into a shared > MAX_TUNNELS` = 256), per-tunnel relay tasks draining into a shared
> inbound queue, `SessionOwner` (the owner wrapper shared with the > inbound queue, `SessionOwner` (the owner wrapper shared with the
+26 -25
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/core-consumer-associate id: implementation/core-consumer-associate
name: "Core — consumer ASSOCIATE: AssociateSession + e2e tests" name: "Core: consumer ASSOCIATE, AssociateSession + e2e tests"
status: completed status: completed
depends_on: [implementation/core-consumer-connect, implementation/core-producer-associate] depends_on: [implementation/core-consumer-connect, implementation/core-producer-associate]
scope: moderate scope: moderate
@@ -20,20 +20,20 @@ Deliverables (port from `/workspace/alksocks-udp-poc/src/consumer.rs`,
adapted to the crate's wire codec): adapted to the crate's wire codec):
1. **`AssociateSession::associate(client)`**: opens the channel, 1. **`AssociateSession::associate(client)`**: opens the channel,
negotiates (NoAuth), sends ASSOCIATE, reads the reply — negotiates (NoAuth), sends ASSOCIATE, reads the reply;
**receives the sentinel BND.ADDR and never uses it for **receives the sentinel BND.ADDR and never uses it for
addressing** (the handle is the channel; ADR-003). addressing** (the handle is the channel; ADR-003).
2. **`send_datagram(dst, bytes)`**: frames once 2. **`send_datagram(dst, bytes)`**: frames once
(`[len: u16 BE][addr + data]` via the crate's wire codec — the (`[len: u16 BE][addr + data]` via the crate's wire codec, the
framing-once mirror) and writes the frame. framing-once mirror) and writes the frame.
3. **`recv_datagram()`** returns `(source_addr, bytes)` per 3. **`recv_datagram()`** returns `(source_addr, bytes)` per
datagram — **the incremental reader state (mid-frame position + datagram, and **the incremental reader state (mid-frame position +
batched-datagram pending queue) persists across calls**: a batched-datagram pending queue) persists across calls**: a
datagram split across channel chunks survives a caller-driven datagram split across channel chunks survives a caller-driven
receive loop (POC #2's finding — the first cut dropped the reader receive loop (POC #2's finding: the first cut dropped the reader
state per call; the 2-byte-prefix chunk test exposed it). Write state per call; the 2-byte-prefix chunk test exposed it). Write
the persistence test (the POC's chunk-splitting echo shape). the persistence test (the POC's chunk-splitting echo shape).
4. **`shutdown()`** half-closes the stream — the mux's implicit-EOF 4. **`shutdown()`** half-closes the stream: the mux's implicit-EOF
sentinel reaches the producer's stage as EOF and the association sentinel reaches the producer's stage as EOF and the association
ends (association lifetime = channel lifetime). ends (association lifetime = channel lifetime).
5. **Teardown ownership**: the session owns the channel; on Drop it 5. **Teardown ownership**: the session owns the channel; on Drop it
@@ -68,12 +68,12 @@ adapted to the crate's wire codec):
- docs/architecture/decisions/003-udp-handle-swap.md (the consumer - docs/architecture/decisions/003-udp-handle-swap.md (the consumer
mirror invariants) mirror invariants)
- POC source: `/workspace/alksocks-udp-poc/src/consumer.rs` - POC source: `/workspace/alksocks-udp-poc/src/consumer.rs`
(`UdpAssociation` — the reader-state finding's origin) (`UdpAssociation`, the reader-state finding's origin)
## Notes ## Notes
- **`send_datagram` takes `impl ToTargetAddr`** (the CONNECT - **`send_datagram` takes `impl ToTargetAddr`** (the CONNECT
session's target-construction posture — ADR-012: IP-string forms session's target-construction posture; under ADR-012, IP-string forms
parse, domains pass through unresolved), not the POC's parse, domains pass through unresolved), not the POC's
`&TargetAddr`: the same entry shape as `connect`. A `&TargetAddr`: the same entry shape as `connect`. A
`ToTargetAddr for &TargetAddr` impl was added to the forked `ToTargetAddr for &TargetAddr` impl was added to the forked
@@ -81,7 +81,7 @@ adapted to the crate's wire codec):
`TargetAddr` value across sends. `TargetAddr` value across sends.
- **Teardown ownership matches the CONNECT/BIND sessions** (the - **Teardown ownership matches the CONNECT/BIND sessions** (the
crate's posture, strengthened from the POC's "close/join"): the crate's posture, strengthened from the POC's "close/join"): the
session owns the adopted channel entry — Drop sync-reaps session owns the adopted channel entry; Drop sync-reaps
(`teardown_channel`, best-effort; `Drop` cannot await) + the data (`teardown_channel`, best-effort; `Drop` cannot await) + the data
plane EOFs structurally (the mux's implicit-EOF sentinel); plane EOFs structurally (the mux's implicit-EOF sentinel);
`shutdown()` half-closes (the graceful path, returning the reap `shutdown()` half-closes (the graceful path, returning the reap
@@ -92,27 +92,28 @@ adapted to the crate's wire codec):
- **`recv_datagram` returns `Result<Option<(TargetAddr, Bytes)>>`** - **`recv_datagram` returns `Result<Option<(TargetAddr, Bytes)>>`**
(the POC's shape): `Ok(None)` = channel EOF (the association's (the POC's shape): `Ok(None)` = channel EOF (the association's
end); a stream end mid-frame is fail-loud (an `UnexpectedEof` end); a stream end mid-frame is fail-loud (an `UnexpectedEof`
I/O error through `SocksSessionError::Rfc` — never a silent I/O error through `SocksSessionError::Rfc`; never a silent
partial datagram); an undecodable frame (garbage ATYP) errors partial datagram); an undecodable frame (garbage ATYP) errors
loud through the wire codec's `WireError` → RFC mapping loud through the wire codec's `WireError` → RFC mapping
(`AddrError` class for address-encoding refusals, (`AddrError` class for address-encoding refusals,
`UdpHeaderError` for the garbage shapes — the consumer-side `UdpHeaderError` for the garbage shapes, the consumer-side
mirror of the producer stage's `UndecodableFrame` exit). mirror of the producer stage's `UndecodableFrame` exit).
- **The pending queue stores decoded datagrams** (`(TargetAddr, - **The pending queue stores decoded datagrams** (`(TargetAddr,
Bytes)`) — the POC stored pre-decode inner bytes; decoded storage Bytes)`) rather than the POC's pre-decode inner bytes; decoded
keeps the delivery shape uniform (decode once per frame either storage keeps the delivery shape uniform (decode once per frame
way; no wire.rs change needed — `decode_inner` stays private). either way; no wire.rs change needed, since `decode_inner` stays
private).
- **Unit tests ride a duplex-backed session** (`skip_auth`'s - **Unit tests ride a duplex-backed session** (`skip_auth`'s
handshake-free `use_stream` path over a `BiStream::from_joined` handshake-free `use_stream` path over a `BiStream::from_joined`
duplex — no channels needed) for the reader-internals probes: duplex, no channels needed) for the reader-internals probes:
reader-state persistence (the 2-byte-prefix chunk shape, the POC reader-state persistence (the 2-byte-prefix chunk shape, the POC
finding's trigger), mid-frame EOF fail-loud, garbage-ATYP fail- finding's trigger), mid-frame EOF fail-loud, garbage-ATYP fail-
loud, and the wire-error mapping table. loud, and the wire-error mapping table.
- **The e2e split-datagram test uses a 60 KiB payload** (60007 - **The e2e split-datagram test uses a 60 KiB payload** (60007
inner bytes — legal under the u16 joint bound, well over the 16 inner bytes, legal under the u16 joint bound, well over the 16
KB read buffer): first → big → third, in order, with the big KB read buffer): first → big → third, in order, with the big
datagram reassembling across the call boundary and the third datagram reassembling across the call boundary and the third
drained through the pending queue — both halves of the drained through the pending queue; both halves of the
persistence invariant pinned end to end. persistence invariant pinned end to end.
- **No harness changes**: the consumer-side topology's - **No harness changes**: the consumer-side topology's
`wire_consumer_side_with` + the producer-associate task's `wire_consumer_side_with` + the producer-associate task's
@@ -127,20 +128,20 @@ adapted to the crate's wire codec):
Implemented the UDP ASSOCIATE consumer session in `src/consumer.rs` Implemented the UDP ASSOCIATE consumer session in `src/consumer.rs`
(POC #2's shape, adapted to the crate's teardown-owning session (POC #2's shape, adapted to the crate's teardown-owning session
posture): `AssociateSession::associate` (open → no-auth → ASSOCIATE posture): `AssociateSession::associate` (open → no-auth → ASSOCIATE
→ reply, capturing the sentinel BND.ADDR as `relay_addr()` — → reply, capturing the sentinel BND.ADDR as `relay_addr()`,
received, never used for addressing; the ASSOCIATE DST.ADDR is the received but never used for addressing; the ASSOCIATE DST.ADDR is the
vanilla-mirror unspecified address), `send_datagram` (frames once vanilla-mirror unspecified address), `send_datagram` (frames once
via the crate's wire `frame()` — the framing-once mirror, the joint via the crate's wire `frame()` (the framing-once mirror, the joint
bound enforced at frame time), `recv_datagram` (the incremental bound enforced at frame time), `recv_datagram` (the incremental
`DatagramReader` + batched-datagram pending queue, **state that `DatagramReader` + batched-datagram pending queue, **state that
persists across calls** — the POC's reader-state finding, with the persists across calls**; that is the POC's reader-state finding, with the
fail-loud shapes: mid-frame EOF and undecodable frames), `shutdown` fail-loud shapes: mid-frame EOF and undecodable frames), `shutdown`
(the half-close; the mux's implicit-EOF sentinel reaches the (the half-close; the mux's implicit-EOF sentinel reaches the
producer's stage as EOF — association lifetime = channel lifetime), producer's stage as EOF; association lifetime = channel lifetime),
plus `split`/`into_inner` accessors and the CONNECT sessions' plus `split`/`into_inner` accessors and the CONNECT sessions'
teardown ownership (Drop sync-reap + structural EOF, `close()` teardown ownership (Drop sync-reap + structural EOF, `close()`
graceful, teardown-at-birth on in-stream refusals, no `Clone` — graceful, teardown-at-birth on in-stream refusals, no `Clone`,
`compile_fail`-asserted). Typed error surface throughout: open since `compile_fail` is asserted there). Typed error surface throughout: open
failures are `SocksSessionError::Open` (ADR-049 reasons); the failures are `SocksSessionError::Open` (ADR-049 reasons); the
missing-egress 0x07 and factory-failure codes surface from missing-egress 0x07 and factory-failure codes surface from
`associate` as `Rfc`. E2E suite (`tests/consumer-associate.rs`, 8 `associate` as `Rfc`. E2E suite (`tests/consumer-associate.rs`, 8
@@ -153,6 +154,6 @@ Drop teardown + connection health, the missing-egress refusal
(typed 0x07, no phantom channel), the egress-factory failure (typed 0x07, no phantom channel), the egress-factory failure
mapping, and the CONNECT+ASSOCIATE regression guard on one mapping, and the CONNECT+ASSOCIATE regression guard on one
registration (interleaved, both serving). Verification: `cargo registration (interleaved, both serving). Verification: `cargo
test` 305 (incl. the 3 doctests — the compile-fail assertions ride test` 305 (incl. the 3 doctests; the compile-fail assertions ride
among them), clippy `-D warnings` (all targets), fmt, `cargo doc among them), clippy `-D warnings` (all targets), fmt, `cargo doc
--no-deps`, and both wasm checks clean. --no-deps`, and both wasm checks clean.
+12 -12
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/core-consumer-bind id: implementation/core-consumer-bind
name: "Core — consumer BIND: BindSession (reply#2 reader) + e2e tests" name: "Core: consumer BIND, BindSession (reply#2 reader) + e2e tests"
status: completed status: completed
depends_on: [implementation/core-consumer-connect, implementation/core-producer-bind] depends_on: [implementation/core-consumer-connect, implementation/core-producer-bind]
scope: narrow scope: narrow
@@ -21,11 +21,11 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/consumer.rs`,
adapted to the crate's error surface): adapted to the crate's error surface):
1. **`BindSession::bind(client, target)`**: opens the channel, 1. **`BindSession::bind(client, target)`**: opens the channel,
negotiates, sends the BIND command — the forked client negotiates, sends the BIND command: the forked client
`request()` reads reply#1 (carrying BND.ADDR). `request()` reads reply#1 (carrying BND.ADDR).
2. **The reply#2 reader (`await_inbound`)**: reply#2 is hand-read 2. **The reply#2 reader (`await_inbound`)**: reply#2 is hand-read
with the forked public `read_address` codec (reply#1's codec with the forked public `read_address` codec (reply#1's codec
applied again — **no second codec**, ADR-011's rationale). The applied again with **no second codec**, ADR-011's rationale). The
session then rides the pass-through data plane session then rides the pass-through data plane
(`BindDataPlane`-shaped: `into_inner()`/`get_socket_mut()`). (`BindDataPlane`-shaped: `into_inner()`/`get_socket_mut()`).
3. **Error surface**: a refused BIND (the command gate's 0x02, or a 3. **Error surface**: a refused BIND (the command gate's 0x02, or a
@@ -71,7 +71,7 @@ adapted to the crate's error surface):
session (the alktunnels ADR-005 posture the CONNECT session session (the alktunnels ADR-005 posture the CONNECT session
pinned): `BindSession` captures reply#1's BND.ADDR at `bind` (the pinned): `BindSession` captures reply#1's BND.ADDR at `bind` (the
forked client `request()` returns it once) and exposes the phase forked client `request()` returns it once) and exposes the phase
through borrows — `reply1()` yields the `BindReply1` handle through borrows: `reply1()` yields the `BindReply1` handle
(`bind_addr` + `raw_mut`), `await_inbound(&mut self)` reads reply#2 (`bind_addr` + `raw_mut`), `await_inbound(&mut self)` reads reply#2
and returns the borrowed `BindDataPlane` (`Ok(None)` on a spent and returns the borrowed `BindDataPlane` (`Ok(None)` on a spent
session). The session stays the single teardown owner for every session). The session stays the single teardown owner for every
@@ -81,7 +81,7 @@ adapted to the crate's error surface):
`ReplyError::from_u8` → the forked public `read_address` codec (no `ReplyError::from_u8` → the forked public `read_address` codec (no
second codec, ADR-011). Errors ride `SocksSessionError` (the second codec, ADR-011). Errors ride `SocksSessionError` (the
`SocksError` conversions routed through the forked vocabulary, per `SocksError` conversions routed through the forked vocabulary, per
the crate's error-surface convention — `AddrError`/`ReplyError` are the crate's error-surface convention (`AddrError`/`ReplyError` are
wrapped into `SocksError` first because the session error type wrapped into `SocksError` first because the session error type
exposes `From<SocksError>`, not the RFC inner enums directly). exposes `From<SocksError>`, not the RFC inner enums directly).
- `BindDataPlane<'s>` borrows the session's stream (the CONNECT - `BindDataPlane<'s>` borrows the session's stream (the CONNECT
@@ -91,19 +91,19 @@ adapted to the crate's error surface):
(the POC shape), and `AsyncRead`/`AsyncWrite` are forwarded so the (the POC shape), and `AsyncRead`/`AsyncWrite` are forwarded so the
plane is a plain pipe (`pump_bidi`-shaped consumers work without plane is a plain pipe (`pump_bidi`-shaped consumers work without
the accessors). `BiStream` is not `Sync`, so the plane pins `Send` the accessors). `BiStream` is not `Sync`, so the plane pins `Send`
(runtime probe) — same shape the forked client type has. (runtime probe), the same shape the forked client type has.
- Teardown: Drop = sync reap + structural EOF (the CONNECT posture); - Teardown: Drop = sync reap + structural EOF (the CONNECT posture);
`close()` = half-close (the EOF sentinel reaches the producer's `close()` = half-close (the EOF sentinel reaches the producer's
pump) then reap. Teardown-at-birth on the in-stream refusal path pump) then reap. Teardown-at-birth on the in-stream refusal path
(command-gate 0x02, factory-failure reply#1): the adopted channel (command-gate 0x02, factory-failure reply#1): the adopted channel
is reaped before the error returns. Post-reply#1 failures is reaped before the error returns. Post-reply#1 failures
(accept-stage, quiet-wait garbage) keep the channel — reply#1 (accept-stage, quiet-wait garbage) keep the channel: reply#1
succeeded, the listener existed; `close`/`Drop` reaps. succeeded, the listener existed; `close`/`Drop` reaps.
- The quiet-wait garbage path surfaces at `await_inbound` as an - The quiet-wait garbage path surfaces at `await_inbound` as an
`Io`-shaped error (the producer ends the session with no reply#2; `Io`-shaped error (the producer ends the session with no reply#2;
the consumer's reply#2 read EOFs) — the client-side mirror of the the consumer's reply#2 read EOFs), the client-side mirror of the
producer's `UnexpectedUdpControlGarbage` exit, pinned by test. producer's `UnexpectedUdpControlGarbage` exit, pinned by test.
- Harness reuse: no harness changes — the consumer-side topology's - Harness reuse: no harness changes. The consumer-side topology's
`wire_consumer_side_with` + the producer-bind task's `wire_consumer_side_with` + the producer-bind task's
`echo_accept`/`refusing_accept*` closures cover every test; the `echo_accept`/`refusing_accept*` closures cover every test; the
parked-accept factory for the quiet-wait garbage test is parked-accept factory for the quiet-wait garbage test is
@@ -114,14 +114,14 @@ adapted to the crate's error surface):
Implemented the BIND consumer session in `src/consumer.rs` (POC #5's Implemented the BIND consumer session in `src/consumer.rs` (POC #5's
shape, adapted to the crate's teardown-owning session posture): shape, adapted to the crate's teardown-owning session posture):
`BindSession::bind` (open → no-auth → BIND → reply#1, capturing `BindSession::bind` (open → no-auth → BIND → reply#1, capturing
BND.ADDR), `reply1()` (the `BindReply1` handle — the app server's BND.ADDR), `reply1()` (the `BindReply1` handle: the app server's
out-of-band dial address + `raw_mut`), `await_inbound` (the POC's out-of-band dial address + `raw_mut`), `await_inbound` (the POC's
`read_reply2` — hand-read with the forked `read_address` codec, no `read_reply2`, hand-read with the forked `read_address` codec, with no
second codec) returning the `BindDataPlane` (`into_inner`/ second codec) returning the `BindDataPlane` (`into_inner`/
`get_socket_mut` + `AsyncRead`/`AsyncWrite` forwards), and the `get_socket_mut` + `AsyncRead`/`AsyncWrite` forwards), and the
CONNECT session's teardown ownership (Drop sync-reap + structural CONNECT session's teardown ownership (Drop sync-reap + structural
EOF, `close()` graceful, teardown-at-birth on in-stream refusals, no EOF, `close()` graceful, teardown-at-birth on in-stream refusals, no
`Clone` — `compile_fail`-asserted). Typed error surface throughout: `Clone`, with `compile_fail` asserting it). Typed error surface throughout:
open failures are `SocksSessionError::Open` (ADR-049 reasons); the open failures are `SocksSessionError::Open` (ADR-049 reasons); the
command gate's 0x02 and factory-failure reply#1 codes surface from command gate's 0x02 and factory-failure reply#1 codes surface from
`bind` as `Rfc`; accept-stage reply#2 codes surface at `bind` as `Rfc`; accept-stage reply#2 codes surface at
+18 -17
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/core-consumer-connect id: implementation/core-consumer-connect
name: "Core — consumer half: Socks5ChannelSession (CONNECT) + e2e tests" name: "Core: consumer half, Socks5ChannelSession (CONNECT) + e2e tests"
status: completed status: completed
depends_on: [implementation/core-producer-connect] depends_on: [implementation/core-producer-connect]
scope: narrow scope: narrow
@@ -12,7 +12,7 @@ level: implementation
## Description ## Description
Implement the CONNECT consumer session in `src/consumer.rs` per Implement the CONNECT consumer session in `src/consumer.rs` per
consumer.md §The Channels-Native Sessions: `Socks5ChannelSession` — consumer.md §The Channels-Native Sessions: `Socks5ChannelSession`,
the typed client that opens `channels/socks5/sub` and drives the the typed client that opens `channels/socks5/sub` and drives the
client side of the RFC conversation in-stream (POC #1's client side of the RFC conversation in-stream (POC #1's
`use_stream` shape). `use_stream` shape).
@@ -22,23 +22,24 @@ consumer.rs`, adapted to the crate's params/error surface):
1. **`Socks5ChannelSession::connect(client, target)`**: opens the 1. **`Socks5ChannelSession::connect(client, target)`**: opens the
channel via `ChannelClient::open_channel(OP_SOCKS5_OPEN, params, channel via `ChannelClient::open_channel(OP_SOCKS5_OPEN, params,
SOCKS5_ALPN)` (trivial params — ADR-001), then drives the forked SOCKS5_ALPN)` (trivial params, ADR-001), then drives the forked
client typestate's `use_stream` path over the channel's client typestate's `use_stream` path over the channel's
`BiStream` (generic over the backing stream — the POC #1 fit: `BiStream` (generic over the backing stream, and the POC #1 fit:
`Socks5Stream` impls `AsyncRead + AsyncWrite` itself, so the `Socks5Stream` impls `AsyncRead + AsyncWrite` itself, so the
session over a `BiStream` is the intended use). session over a `BiStream` is the intended use).
2. **Target construction** via `ToTargetAddr` (IP-string forms parse 2. **Target construction** via `ToTargetAddr` (IP-string forms parse
to IP targets; domain form passes through unresolved — ADR-012). to IP targets; domain form passes through unresolved, per
ADR-012).
3. **Post-reply payload pipe**: after the reply, the session is a 3. **Post-reply payload pipe**: after the reply, the session is a
raw byte pipe — expose `split()` (read/write halves) and raw byte pipe. Expose `split()` (read/write halves) and
`into_inner()` (the underlying `Socks5Stream<BiStream>` for `into_inner()` (the underlying `Socks5Stream<BiStream>` for
advanced use). 0 B framing (CONNECT). advanced use). 0 B framing (CONNECT).
4. **Teardown ownership** (alktunnels ADR-005 posture, inherited): 4. **Teardown ownership** (alktunnels ADR-005 posture, inherited):
the session owns the channel; on Drop it closes/joins. No the session owns the channel; on Drop it closes/joins. No
`Clone` (compile-asserted — aliasing a session would alias its `Clone` (compile-asserted, since aliasing a session would alias its
teardown; the alktunnels `compile_fail` doctest shape). teardown; the alktunnels `compile_fail` doctest shape).
5. **Error surface**: open failures are the typed 5. **Error surface**: open failures are the typed
`SocksOpenError` reasons (ADR-049 §4 — a refused session never `SocksOpenError` reasons (ADR-049 §4: a refused session never
yields a phantom channel); a refused target is an RFC reply code yields a phantom channel); a refused target is an RFC reply code
surfaced through the session type (consumer.md §Error surface). surfaced through the session type (consumer.md §Error surface).
6. **Tests** (end-to-end through the harness): connect → echo 6. **Tests** (end-to-end through the harness): connect → echo
@@ -63,7 +64,7 @@ consumer.rs`, adapted to the crate's params/error surface):
- docs/architecture/consumer.md §The Channels-Native Sessions - docs/architecture/consumer.md §The Channels-Native Sessions
- docs/architecture/decisions/003-udp-handle-swap.md (the framing - docs/architecture/decisions/003-udp-handle-swap.md (the framing
mirror — CONNECT is the 0 B case) mirror, where CONNECT is the 0 B case)
- POC source: `/workspace/alksocks-connect-poc/src/consumer.rs` - POC source: `/workspace/alksocks-connect-poc/src/consumer.rs`
- alktunnels template: `/workspace/@alkdev/alktunnels/src/consumer.rs` - alktunnels template: `/workspace/@alkdev/alktunnels/src/consumer.rs`
(the teardown-ownership + no-Clone posture) (the teardown-ownership + no-Clone posture)
@@ -77,23 +78,23 @@ consumer.rs`, adapted to the crate's params/error surface):
over `BiStream::from_joined(recv, send)`, then `request(TCPConnect, over `BiStream::from_joined(recv, send)`, then `request(TCPConnect,
target)`. `Config::default()` is the no-auth posture (the target)`. `Config::default()` is the no-auth posture (the
`skip_auth` knob stays internal to the fork; the identity seam is `skip_auth` knob stays internal to the fork; the identity seam is
the open op's, CF-005/CF-006 — no RFC-facing auth on this path). the open op's, CF-005/CF-006; no RFC-facing auth on this path).
- `split()` returns borrowed halves (`tokio::io::split` over a - `split()` returns borrowed halves (`tokio::io::split` over a
`&mut Socks5Stream<BiStream>`; the `SplitPipe` type alias), not `&mut Socks5Stream<BiStream>`; the `SplitPipe` type alias), not
owned ones: the session keeps teardown ownership for every shape, owned ones: the session keeps teardown ownership for every shape,
including the split-driven one. The owned path is `into_inner()` including the split-driven one. The owned path is `into_inner()`
(`TakenInner { session, inner }` — the session rides along as the (`TakenInner { session, inner }`; the session rides along as the
teardown owner). teardown owner).
- Refusal teardown: an in-stream refusal (Rfc error) tears the - Refusal teardown: an in-stream refusal (Rfc error) tears the
adopted channel down inside `connect` before the error returns adopted channel down inside `connect` before the error returns
(teardown-at-birth; the phantom-channel property, tested both (teardown-at-birth; the phantom-channel property, tested both
sides). The producer's own handler-exit teardown races the sides). The producer's own handler-exit teardown races the
consumer's — the leak-check probe polls past that race. consumer's, and the leak-check probe polls past that race.
- Drop is the sync-reap posture (`teardown_channel` is sync; - Drop is the sync-reap posture (`teardown_channel` is sync;
`Drop` cannot await) + the structural EOF of the dropped pipe; the `Drop` cannot await) + the structural EOF of the dropped pipe; the
graceful path is `close()` (write-half shutdown first, then reap). graceful path is `close()` (write-half shutdown first, then reap).
- Harness: added the consumer-side topology (`wire_consumer_side[_with]` - Harness: added the consumer-side topology (`wire_consumer_side[_with]`,
— the consumer dials with a real `ChannelClient`; the producer where the consumer dials with a real `ChannelClient` and the producer
accepts with the real registration) plus `witnessing_echo_dial` accepts with the real registration) plus `witnessing_echo_dial`
(the dial-witness target-form probe). The `allow(dead_code)` attr (the dial-witness target-form probe). The `allow(dead_code)` attr
rides the alktunnels harness posture (each test binary compiles the rides the alktunnels harness posture (each test binary compiles the
@@ -103,15 +104,15 @@ consumer.rs`, adapted to the crate's params/error surface):
## Summary ## Summary
Implemented `Socks5ChannelSession` (CONNECT) in `src/consumer.rs` — Implemented `Socks5ChannelSession` (CONNECT) in `src/consumer.rs`:
`connect`/`channel_id`/`split`/`into_inner`/`close` + `Drop` teardown `connect`/`channel_id`/`split`/`into_inner`/`close` + `Drop` teardown
ownership and the `SplitPipe`/`TakenInner` companions — with the ownership and the `SplitPipe`/`TakenInner` companions, with the
typed error surface (`SocksOpenError` for opens, the RFC reply code typed error surface (`SocksOpenError` for opens, the RFC reply code
through `SocksSessionError::Rfc` for refused targets) and through `SocksSessionError::Rfc` for refused targets) and
teardown-at-birth on the in-stream refusal path. E2E suite teardown-at-birth on the in-stream refusal path. E2E suite
(`tests/consumer-connect.rs`, 9 tests over the new consumer-side (`tests/consumer-connect.rs`, 9 tests over the new consumer-side
harness topology): echo round-trip + CF-006 witness, target forms harness topology): echo round-trip + CF-006 witness, target forms
(IP-string parses / domain passes through — ADR-012, wire-observed (IP-string parses / domain passes through, per ADR-012 and wire-observed
at the dial), refused dial → typed RFC error + no phantom channel, at the dial), refused dial → typed RFC error + no phantom channel,
FORBIDDEN open → typed open error + no session, drop teardown + FORBIDDEN open → typed open error + no session, drop teardown +
connection health, half-close EOF semantics, `into_inner` pairing, connection health, half-close EOF semantics, `into_inner` pairing,
+22 -22
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/core-crate-deps id: implementation/core-crate-deps
name: Core — crate dependencies + module skeleton (alkcall wiring) name: "Core: crate dependencies + module skeleton (alkcall wiring)"
status: completed status: completed
depends_on: [] depends_on: []
scope: narrow scope: narrow
@@ -14,35 +14,35 @@ level: implementation
Add the dependency surface the wrapper layers need and the module Add the dependency surface the wrapper layers need and the module
skeleton so the batch-1 core tasks have their targets. This is the skeleton so the batch-1 core tasks have their targets. This is the
batch-1 analogue of the vendor scaffolding task: the crate gains batch-1 analogue of the vendor scaffolding task: the crate gains
`alkcall` (its substrate — first consumption of the 0.8.0 surface) and `alkcall` (its substrate, and first consumption of the 0.8.0 surface) and
the module files named in overview.md §Module Map, born wasm-clean. the module files named in overview.md §Module Map, born wasm-clean.
Deliverables: Deliverables:
1. **`Cargo.toml` `[dependencies]`** — add to the existing trio 1. **`Cargo.toml` `[dependencies]`**: add to the existing trio
(tokio wasm-clean subset, tracing, thiserror): (tokio wasm-clean subset, tracing, thiserror):
- `alkcall = "0.8.0"` (pinned per AGENTS.md convention 11 — bump - `alkcall = "0.8.0"` (pinned per AGENTS.md convention 11; bump
deliberately, never transitively) deliberately, never transitively)
- `serde = { version = "1", features = ["derive"] }`, - `serde = { version = "1", features = ["derive"] }`,
`serde_json = "1"` (the params schema + spec builder) `serde_json = "1"` (the params schema + spec builder)
- `bytes = "1"` (the wire codec's buffer types) - `bytes = "1"` (the wire codec's buffer types)
- `futures = { version = "0.3", default-features = false, - `futures = { version = "0.3", default-features = false,
features = ["std"] }` (BoxFuture for the seam callbacks — the features = ["std"] }` (BoxFuture for the seam callbacks, the
alktunnels precedent; verify the wasm-clean subset compiles) alktunnels precedent; verify the wasm-clean subset compiles)
- tokio stays `default-features = false` with the wasm-clean - tokio stays `default-features = false` with the wasm-clean
subset (`rt`, `sync`, `io-util`, `macros`, `time`) — **never subset (`rt`, `sync`, `io-util`, `macros`, `time`); **never
`features = ["full"]`** (AGENTS.md convention 4). No `features = ["full"]`** (AGENTS.md convention 4). No
`tokio/net`, no `socket2` — those ride `local` in batch 2. `tokio/net`, no `socket2`: those ride `local` in batch 2.
- `[features]` stays `default = []`, `local = []`, - `[features]` stays `default = []`, `local = []`,
`composed = []` — declared empty by design (ADR-009); populated `composed = []`, declared empty by design (ADR-009); populated
by their modules' batch-2 tasks. by their modules' batch-2 tasks.
2. **`src/lib.rs`** — keep `#![forbid(unsafe_code)]` and the crate 2. **`src/lib.rs`**: keep `#![forbid(unsafe_code)]` and the crate
docs; declare the module skeleton per overview.md §Module Map: docs; declare the module skeleton per overview.md §Module Map:
`params`, `wire`, `producer`, `consumer`, `egress`, `error` — `params`, `wire`, `producer`, `consumer`, `egress`, `error`;
one-line stub files, each with a module doc naming its filling one-line stub files, each with a module doc naming its filling
task (the vendor-scaffolding precedent). `rfc1928` stays as is. task (the vendor-scaffolding precedent). `rfc1928` stays as is.
3. **Re-export posture**: `lib.rs` re-exports public API per 3. **Re-export posture**: `lib.rs` re-exports public API per
convention 15 — for now the module declarations only; each convention 15; for now the module declarations only, and each
implementation task adds its own re-exports. implementation task adds its own re-exports.
## Acceptance Criteria ## Acceptance Criteria
@@ -50,7 +50,7 @@ Deliverables:
- [x] `cargo test` passes - [x] `cargo test` passes
- [x] `cargo clippy --all-targets -- -D warnings` clean - [x] `cargo clippy --all-targets -- -D warnings` clean
- [x] `cargo fmt --check` clean - [x] `cargo fmt --check` clean
- [x] `cargo check --target wasm32-unknown-unknown` clean — the wasm - [x] `cargo check --target wasm32-unknown-unknown` clean; the wasm
gate stays green with alkcall + serde + bytes + futures in the gate stays green with alkcall + serde + bytes + futures in the
graph (alkcall 0.8.0's default crate is wasm-clean; verify, do graph (alkcall 0.8.0's default crate is wasm-clean; verify, do
not assume) not assume)
@@ -80,28 +80,28 @@ Deliverables:
self-contained on `wasm32-unknown-unknown`. The `futures = { self-contained on `wasm32-unknown-unknown`. The `futures = {
default-features = false, features = ["std"] }` subset compiles in default-features = false, features = ["std"] }` subset compiles in
the same graph (probe build in `/tmp/opencode/wasm-probe`). the same graph (probe build in `/tmp/opencode/wasm-probe`).
- **Cargo.toml** — added `alkcall = "0.8.0"` (pinned, not a range — - **Cargo.toml**: added `alkcall = "0.8.0"` (pinned, not a range, per
convention 11), `serde = { version = "1", features = ["derive"] }`, convention 11), `serde = { version = "1", features = ["derive"] }`,
`serde_json = "1"`, `bytes = "1"`, and `serde_json = "1"`, `bytes = "1"`, and
`futures = { version = "0.3", default-features = false, features = `futures = { version = "0.3", default-features = false, features =
["std"] }` (the BoxFuture seam shape; the std-only subset, not ["std"] }` (the BoxFuture seam shape; the std-only subset, not
default features — the latter is the same set in practice, but the default features. The latter is the same set in practice, but the
explicit subset is the documented posture). tokio unchanged (the explicit subset is the documented posture). tokio unchanged (the
wasm-clean subset, `default-features = false` — never `full`); the wasm-clean subset, `default-features = false`; never `full`); the
full-tokio dev-dep stays native-only. `[features]` untouched full-tokio dev-dep stays native-only. `[features]` untouched
(`default = []`, `local = []`, `composed = []` — ADR-009, populated (`default = []`, `local = []`, `composed = []`; ADR-009, populated
by batch-2). by batch-2).
- **lib.rs** — kept `#![forbid(unsafe_code)]` + crate docs; declared - **lib.rs**: kept `#![forbid(unsafe_code)]` + crate docs; declared
the six skeleton modules per overview.md §Module Map: `params`, the six skeleton modules per overview.md §Module Map: `params`,
`wire`, `producer`, `consumer`, `egress`, `error` (alphabetical `wire`, `producer`, `consumer`, `egress`, `error` (alphabetical
declaration order after `rfc1928`; rustfmt-sorted). No re-exports declaration order after `rfc1928`; rustfmt-sorted). No re-exports
yet (convention 15 posture: each implementation task adds its own). yet (convention 15 posture: each implementation task adds its own).
- **Stub files** — one-line module docs (2–5 lines) each naming its - **Stub files**: one-line module docs (2–5 lines) each naming its
filling task: `params`/`error` → core-params-errors, `wire` → filling task: `params`/`error` → core-params-errors, `wire` →
core-wire-codec, `egress` → core-seam-types, `producer` → core-wire-codec, `egress` → core-seam-types, `producer` →
core-producer-connect (+ associates/bind), `consumer` → core-producer-connect (+ associates/bind), `consumer` →
core-consumer-connect (+ associates/bind). `door.rs`, `local/`, core-consumer-connect (+ associates/bind). `door.rs`, `local/`,
`composed/` are deliberately absent — feature-gated batch-2 modules `composed/` are deliberately absent, being feature-gated batch-2 modules
born with their features, per the overview module map's local/ born with their features, per the overview module map's local/
composed posture. composed posture.
- Dep-graph spot checks: `cargo tree --target - Dep-graph spot checks: `cargo tree --target
@@ -109,7 +109,7 @@ Deliverables:
nothing (not in the wasm normal graph); the normal-only tokio nothing (not in the wasm normal graph); the normal-only tokio
feature set on both native and wasm targets is exactly feature set on both native and wasm targets is exactly
`bytes/default io-util macros rt sync time` (the `net`/`full` `bytes/default io-util macros rt sync time` (the `net`/`full`
features appear only via the dev-dependency tokio — the test-only features appear only via the dev-dependency tokio, which is the test-only
surface, by design). surface, by design).
## Summary ## Summary
@@ -117,7 +117,7 @@ Deliverables:
Added the batch-1 dependency surface and the module skeleton: Added the batch-1 dependency surface and the module skeleton:
`Cargo.toml` gains `alkcall = "0.8.0"` (the substrate pin, `Cargo.toml` gains `alkcall = "0.8.0"` (the substrate pin,
convention 11), `serde`/`serde_json`, `bytes`, and the std-only convention 11), `serde`/`serde_json`, `bytes`, and the std-only
`futures` subset — tokio's wasm-clean subset and the empty `futures` subset, with tokio's wasm-clean subset and the empty
`local`/`composed` features unchanged; `src/lib.rs` declares the six `local`/`composed` features unchanged; `src/lib.rs` declares the six
skeleton modules (`params`, `wire`, `producer`, `consumer`, `egress`, skeleton modules (`params`, `wire`, `producer`, `consumer`, `egress`,
`error`) per overview.md §Module Map, each a stub with a module doc `error`) per overview.md §Module Map, each a stub with a module doc
@@ -125,7 +125,7 @@ naming its filling task (the vendor-scaffolding precedent); no
re-exports yet. Wasm-cleanliness verified before and after: alkcall re-exports yet. Wasm-cleanliness verified before and after: alkcall
0.8.0's crates.io artifact compiles clean on 0.8.0's crates.io artifact compiles clean on
`wasm32-unknown-unknown` with no patching (probe build), and the `wasm32-unknown-unknown` with no patching (probe build), and the
crate's checks all pass — `cargo test` (39+37 tests, 2 doc-ignored), crate's checks all pass: `cargo test` (39+37 tests, 2 doc-ignored),
`cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`,
`cargo doc --no-deps`, both wasm checks, and the `mio`/`socket2`/ `cargo doc --no-deps`, both wasm checks, and the `mio`/`socket2`/
`tokio::net` absence spot-checks on the default (normal) dependency `tokio::net` absence spot-checks on the default (normal) dependency
+19 -19
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/core-integration-suite id: implementation/core-integration-suite
name: Core — cross-cutting integration suite + docs sync name: "Core: cross-cutting integration suite + docs sync"
status: completed status: completed
depends_on: [implementation/core-consumer-associate, implementation/core-consumer-bind] depends_on: [implementation/core-consumer-associate, implementation/core-consumer-bind]
scope: moderate scope: moderate
@@ -25,7 +25,7 @@ Deliverables:
served on the same `channels/socks5/sub` op, interleaved served on the same `channels/socks5/sub` op, interleaved
sessions on one connection. sessions on one connection.
- **Concurrency**: multiple concurrent sessions of mixed command - **Concurrency**: multiple concurrent sessions of mixed command
types over one channels connection (the mux/demux under load — types over one channels connection (the mux/demux under load;
the POCs tested sequentially; this pins concurrency). the POCs tested sequentially; this pins concurrency).
- **Teardown semantics matrix**: client half-close ends a CONNECT - **Teardown semantics matrix**: client half-close ends a CONNECT
data plane cleanly (the producer's pump exits, the connection data plane cleanly (the producer's pump exits, the connection
@@ -36,7 +36,7 @@ Deliverables:
- **Identity/scope matrix**: `socks5:open`-only (CONNECT + - **Identity/scope matrix**: `socks5:open`-only (CONNECT +
ASSOCIATE yes, BIND 0x02), `socks5:bind`-only (BIND yes, ASSOCIATE yes, BIND 0x02), `socks5:bind`-only (BIND yes,
CONNECT/ASSOCIATE FORBIDDEN at the open gate), both-scopes CONNECT/ASSOCIATE FORBIDDEN at the open gate), both-scopes
(everything) — the two-grants-never-implied posture (ADR-006) (everything); the two-grants-never-implied posture (ADR-006)
pinned end to end. pinned end to end.
- **The readiness contract** re-pinned at the integration level - **The readiness contract** re-pinned at the integration level
(the producer-associate task owns the unit-level test; this (the producer-associate task owns the unit-level test; this
@@ -47,12 +47,12 @@ Deliverables:
follow the decomposition phase" line updated). follow the decomposition phase" line updated).
- Any spec drift the implementation surfaced (naming, shapes, - Any spec drift the implementation surfaced (naming, shapes,
the wire.md/bast.md residue already fixed in the wire-codec the wire.md/bast.md residue already fixed in the wire-codec
task) reconciled in the same change — never silently diverged. task) reconciled in the same change, never silently diverged.
- `lib.rs` crate docs updated to describe the landed surface - `lib.rs` crate docs updated to describe the landed surface
(producer + consumer + wire + seams), pointing at the specs. (producer + consumer + wire + seams), pointing at the specs.
3. **Full verification sweep** (the batch gate's input): 3. **Full verification sweep** (the batch gate's input):
`cargo test`, `cargo test --all-features` (features are still `cargo test`, `cargo test --all-features` (features are still
empty — verify the flag combination compiles), clippy empty, so verify the flag combination compiles), clippy
`--all-targets -- -D warnings`, `cargo fmt --check`, both wasm `--all-targets -- -D warnings`, `cargo fmt --check`, both wasm
checks, and `cargo doc --no-deps` (docs changed). checks, and `cargo doc --no-deps` (docs changed).
@@ -64,7 +64,7 @@ Deliverables:
CONNECT/ASSOCIATE FORBIDDEN at the open gate"). The landed ADR-006 CONNECT/ASSOCIATE FORBIDDEN at the open gate"). The landed ADR-006
+ implementation pin the open op's `required_scopes = + implementation pin the open op's `required_scopes =
["socks5:open"]` **unconditionally** (the single op, all three ["socks5:open"]` **unconditionally** (the single op, all three
commands; ADR-001) — a bind-only identity fails closed at the open commands; ADR-001); a bind-only identity fails closed at the open
gate for every command (FORBIDDEN, the establisher never runs, no gate for every command (FORBIDDEN, the establisher never runs, no
phantom channel), and a BIND-capable deployment grants both scopes phantom channel), and a BIND-capable deployment grants both scopes
explicitly. The suite pins the landed posture explicitly. The suite pins the landed posture
@@ -72,28 +72,28 @@ Deliverables:
producer- and consumer-side; ADR-006 §Consequences now states the producer- and consumer-side; ADR-006 §Consequences now states the
asymmetry explicitly (never implied, in either direction). The asymmetry explicitly (never implied, in either direction). The
task's phrasing was a Phase-0-era guess at the gate shape, not a task's phrasing was a Phase-0-era guess at the gate shape, not a
spec row the code contradicted — the spec (ADR-006 + producer.md) spec row the code contradicted: the spec (ADR-006 + producer.md)
and the implementation already agreed. and the implementation already agreed.
- **Test-shape notes:** the suite rides the established - **Test-shape notes:** the suite rides the established
`tests/harness.rs` topologies (the raw producer-side topology — `tests/harness.rs` topologies (the raw producer-side topology,
the producer's serving side allocates channel ids — and the where the producer's serving side allocates channel ids; and the
consumer-side topology — the consumer allocates; the mux/demux is consumer-side topology, where the consumer allocates; the mux/demux is
pinned in both allocation directions). The mixed-command pinned in both allocation directions). The mixed-command
concurrency tests box the per-command round-trip futures into one concurrency tests box the per-command round-trip futures into one
`Vec<RoundTrip>` (`Pin<Box<dyn Future>>` — the three async fns `Vec<RoundTrip>` (`Pin<Box<dyn Future>>`; the three async fns
have distinct opaque types, so a shared boxed type is the have distinct opaque types, so a shared boxed type is the
`join_all` vehicle). The BIND client-gone row uses a parked accept `join_all` vehicle). The BIND client-gone row uses a parked accept
factory so the quiet-wait exit is the only possible end (the factory so the quiet-wait exit is the only possible end (the
deterministic client-gone probe). deterministic client-gone probe).
- **Readiness contract under load:** the integration-level pin - **Readiness contract under load:** the integration-level pin
reuses the producer-associate unit test's hot-`Ok(None)` egress reuses the producer-associate unit test's hot-`Ok(None)` egress
shape across concurrent associations (with a CONNECT interleaved) shape across concurrent associations (with a CONNECT interleaved),
— the stage's re-park tolerance must hold concurrently, not just so the stage's re-park tolerance must hold concurrently, not just
sequentially. sequentially.
## Summary ## Summary
Landed the batch-1 closing task: `tests/core-integration.rs` — nine Landed the batch-1 closing task: `tests/core-integration.rs`, nine
cross-cutting integration tests pinning the whole protocol core end cross-cutting integration tests pinning the whole protocol core end
to end over the channels harness (the alkcall 0.8.0 establishment to end over the channels harness (the alkcall 0.8.0 establishment
surface, both topology directions): surface, both topology directions):
@@ -105,7 +105,7 @@ surface, both topology directions):
ends only its own session (mux/demux isolation). ends only its own session (mux/demux isolation).
2. **Concurrency**: nine concurrent mixed-command sessions over one 2. **Concurrency**: nine concurrent mixed-command sessions over one
channels connection (the raw topology), six through the typed channels connection (the raw topology), six through the typed
consumer sessions (the consumer-side topology — the other consumer sessions (the consumer-side topology, the other
allocation direction), then a fresh round-trip of each command allocation direction), then a fresh round-trip of each command
after the burst; no channel entry leaks on either side. after the burst; no channel entry leaks on either side.
3. **The teardown matrix** (wire.md §Sentinels and lifecycle): a 3. **The teardown matrix** (wire.md §Sentinels and lifecycle): a
@@ -126,13 +126,13 @@ surface, both topology directions):
branch. branch.
Docs sync (the architecture-sync check): `docs/architecture/README.md` Docs sync (the architecture-sync check): `docs/architecture/README.md`
Current State rewritten — "the protocol core is implemented," the Current State rewritten: "the protocol core is implemented," the
landed per-module inventory, `tests/core-integration.rs` named as the landed per-module inventory, `tests/core-integration.rs` named as the
batch-1 pin, batch 2 (`local`/`composed`) as the follow; the batch-1 pin, batch 2 (`local`/`composed`) as the follow; the
"remaining layers follow the decomposition phase" residue gone. "remaining layers follow the decomposition phase" residue gone.
`consumer.md` §Wiring reconciled to the landed signatures `consumer.md` §Wiring reconciled to the landed signatures
(`&ChannelClient` constructors, `impl ToTargetAddr` datagrams, the (`&ChannelClient` constructors, `impl ToTargetAddr` datagrams, the
`Option`-shaped `recv_datagram` — `None` is the channel-EOF `Option`-shaped `recv_datagram` (`None` is the channel-EOF
association end, never an empty datagram). ADR-006 §Consequences now association end, never an empty datagram). ADR-006 §Consequences now
states the grant asymmetry explicitly. `src/lib.rs` crate docs states the grant asymmetry explicitly. `src/lib.rs` crate docs
rewritten to describe the landed surface (rfc1928 fork, wire codec, rewritten to describe the landed surface (rfc1928 fork, wire codec,
@@ -161,8 +161,8 @@ features `local`/`composed` compile), `cargo clippy --all-targets --
## Notes ## Notes
> Filled by the implementation agent — see the Notes above. > Filled by the implementation agent: see the Notes above.
## Summary ## Summary
> Filled on completion — see the Summary above. > Filled on completion: see the Summary above.
+18 -18
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/core-params-errors id: implementation/core-params-errors
name: Core — params schema + scope constants + typed error surface name: "Core: params schema + scope constants + typed error surface"
status: completed status: completed
depends_on: [implementation/core-crate-deps] depends_on: [implementation/core-crate-deps]
scope: narrow scope: narrow
@@ -12,7 +12,7 @@ level: implementation
## Description ## Description
Implement `src/params.rs` and `src/error.rs` per wire.md §The Open Implement `src/params.rs` and `src/error.rs` per wire.md §The Open
Op, ADR-001, and ADR-006 — the wire-stable surface (the one-way door) Op, ADR-001, and ADR-006, i.e. the wire-stable surface (the one-way door)
and the typed open-error vocabulary. Template: and the typed open-error vocabulary. Template:
`/workspace/@alkdev/alktunnels/src/params.rs` (the sibling's spec `/workspace/@alkdev/alktunnels/src/params.rs` (the sibling's spec
builder + scope-constant posture). builder + scope-constant posture).
@@ -25,19 +25,19 @@ Deliverables:
"socks5:open"`, `SOCKS5_BIND_SCOPE = "socks5:bind"` (ADR-001/ "socks5:open"`, `SOCKS5_BIND_SCOPE = "socks5:bind"` (ADR-001/
ADR-006; the scope-string convention `<alpn-family>: ADR-006; the scope-string convention `<alpn-family>:
<capability>`; stable once published). <capability>`; stable once published).
- `socks5_open_spec() -> OperationSpec` — `Sub`-typed, external, - `socks5_open_spec() -> OperationSpec`: `Sub`-typed, external,
channel-open marker on `SOCKS5_ALPN`, **trivial params**: channel-open marker on `SOCKS5_ALPN`, **trivial params**:
input schema `{type: object}`, `additionalProperties: false` input schema `{type: object}`, `additionalProperties: false`
(empty params stay valid forever — ADR-001's additive-only (empty params stay valid forever, by ADR-001's additive-only
extension rule); reply schema `{"channel_id": integer, extension rule); reply schema `{"channel_id": integer,
minimum 1}`; `required_scopes = ["socks5:open"]`. Follow the minimum 1}`; `required_scopes = ["socks5:open"]`. Follow the
alktunnels spec-builder shape exactly (the registry validates alktunnels spec-builder shape exactly (the registry validates
against these schemas). against these schemas).
- The trivial params struct (`Socks5Params {}` — serde - The trivial params struct (`Socks5Params {}`, with serde
`deny_unknown_fields`, empty) for the consumer's open call. `deny_unknown_fields`, empty) for the consumer's open call.
2. **`src/error.rs`** — the typed open-error surface (alkcall 2. **`src/error.rs`**: the typed open-error surface (alkcall
ADR-049 §4): ADR-049 §4):
- `SocksOpenError` — the consumer-side open-failure vocabulary - `SocksOpenError`: the consumer-side open-failure vocabulary
mapping the `channel:open_failed` reasons (`dial_failed` / mapping the `channel:open_failed` reasons (`dial_failed` /
`unknown_resource` / `resource_shortage` / `handler_error` / `unknown_resource` / `resource_shortage` / `handler_error` /
`timeout`) plus the registry's `FORBIDDEN`/`INVALID_INPUT` `timeout`) plus the registry's `FORBIDDEN`/`INVALID_INPUT`
@@ -45,15 +45,15 @@ Deliverables:
`establishment_reason` helper. `establishment_reason` helper.
- The session-level error type for the consumer sessions - The session-level error type for the consumer sessions
(`SocksSessionError`-shaped: open errors vs in-stream (`SocksSessionError`-shaped: open errors vs in-stream
`SocksError` vs I/O) — the exact variant set is the task's `SocksError` vs I/O). The exact variant set is the task's
judgment; keep it faithful to what the sessions actually judgment; keep it faithful to what the sessions actually
surface (a refused session never yields a phantom channel; a surface (a refused session never yields a phantom channel; a
refused target is an RFC reply code surfaced through the refused target is an RFC reply code surfaced through the
session type — consumer.md §Error surface). session type, per consumer.md §Error surface).
3. **lib.rs re-exports** for the new public surface (convention 15). 3. **lib.rs re-exports** for the new public surface (convention 15).
Tests: spec-builder shape assertions (op id, ALPN marker, scopes, Tests: spec-builder shape assertions (op id, ALPN marker, scopes,
schemas — the trivial-params schema rejects unknown fields), params schemas; the trivial-params schema rejects unknown fields), params
serde round-trip (empty object valid; unknown field rejected), the serde round-trip (empty object valid; unknown field rejected), the
error-reason mapping table. error-reason mapping table.
@@ -91,16 +91,16 @@ error-reason mapping table.
(Sub/External/channel-open marker on `alk/socks5`; input schema (Sub/External/channel-open marker on `alk/socks5`; input schema
`{type: object, additionalProperties: false}`; reply schema `{type: object, additionalProperties: false}`; reply schema
`channel_id` integer minimum 1 required; `required_scopes = `channel_id` integer minimum 1 required; `required_scopes =
["socks5:open"]`), and the `establishment_reason` helper re-export `["socks5:open"]`), and the `establishment_reason` helper re-export
(the alktunnels posture — delegates to (the alktunnels posture, which delegates to
`ChannelOpenError::establishment_reason`). `ChannelOpenError::establishment_reason`).
- `src/error.rs`: `SocksOpenError` (single `Open` variant wrapping - `src/error.rs`: `SocksOpenError` (single `Open` variant wrapping
`ChannelOpenError` — the same shape as alktunnels' `ChannelOpenError`, the same shape as alktunnels'
`TunnelOpenError`; the `From` impl covers the `TunnelOpenError`; the `From` impl covers the
`channel:open_failed`/`FORBIDDEN`/`INVALID_INPUT` classes), the `channel:open_failed`/`FORBIDDEN`/`INVALID_INPUT` classes), the
typed `SocksEstablishmentReason` vocabulary (ADR-049 §3's five typed `SocksEstablishmentReason` vocabulary (ADR-049 §3's five
codes, `parse`/`as_str`; unknown codes are `None` — never silently codes, `parse`/`as_str`; unknown codes are `None`, never silently
aliased, the vocabulary is one-way), `SocksOpenError::reason()` as aliased, since the vocabulary is one-way), `SocksOpenError::reason()` as
the typed view, and `SocksSessionError` (open errors vs in-stream the typed view, and `SocksSessionError` (open errors vs in-stream
`Rfc(SocksError)` vs `Io`; the RFC reply-code refusals ride the `Rfc(SocksError)` vs `Io`; the RFC reply-code refusals ride the
session type per consumer.md §Error surface). `SocksError`/ session type per consumer.md §Error surface). `SocksError`/
@@ -110,12 +110,12 @@ error-reason mapping table.
- lib.rs re-exports both modules' public surface (convention 15). - lib.rs re-exports both modules' public surface (convention 15).
- Deliberate deviations from the task text: the params struct - Deliberate deviations from the task text: the params struct
derives `Serialize` (test round-trip needs it; harmless on the derives `Serialize` (test round-trip needs it; harmless on the
wire — the consumer sends `json!({})`); `SocksEstablishmentReason` wire, since the consumer sends `json!({})`); `SocksEstablishmentReason`
is a separate named type rather than an inline match so the is a separate named type rather than an inline match so the
mapping table is testable in both directions; unknown reason codes mapping table is testable in both directions; unknown reason codes
do NOT alias a variant (one-way vocabulary discipline). do NOT alias a variant (one-way vocabulary discipline).
- rustdoc links to `error::SocksOpenError` from `params` are - rustdoc links to `error::SocksOpenError` from `params` are
fully-qualified (`crate::error::…`) — plain `[SocksOpenError]` fully-qualified (`crate::error::…`); plain `[SocksOpenError]`
does not resolve cross-module in this crate's layout. does not resolve cross-module in this crate's layout.
## Summary ## Summary
@@ -130,7 +130,7 @@ open-error vocabulary:
`socks5:open`/`socks5:bind`, trivial empty-object input schema with `socks5:open`/`socks5:bind`, trivial empty-object input schema with
loud `additionalProperties: false`, reply loud `additionalProperties: false`, reply
`{channel_id: integer, minimum 1}` required). `{channel_id: integer, minimum 1}` required).
- `Socks5Params {}` with `deny_unknown_fields` — empty object valid, - `Socks5Params {}` with `deny_unknown_fields`: empty object valid,
unknown fields rejected. unknown fields rejected.
- `error`: `SocksOpenError` mapping the ADR-049 §4 reasons - `error`: `SocksOpenError` mapping the ADR-049 §4 reasons
(`dial_failed`/`unknown_resource`/`resource_shortage`/ (`dial_failed`/`unknown_resource`/`resource_shortage`/
+20 -19
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/core-producer-associate id: implementation/core-producer-associate
name: "Core — producer ASSOCIATE: the select!-driven datagram stage" name: "Core: producer ASSOCIATE, the select!-driven datagram stage"
status: completed status: completed
depends_on: [implementation/core-wire-codec, implementation/core-producer-connect] depends_on: [implementation/core-wire-codec, implementation/core-producer-connect]
scope: moderate scope: moderate
@@ -18,17 +18,17 @@ refusal with the datagram stage (POC #2's validated scope).
Deliverables (port from `/workspace/alksocks-udp-poc/src/producer.rs` Deliverables (port from `/workspace/alksocks-udp-poc/src/producer.rs`
+ `egress.rs`, adapted to the crate's wire codec and seam types): + `egress.rs`, adapted to the crate's wire codec and seam types):
1. **The ASSOCIATE arm**: `reply_success(sentinel)` — the sentinel 1. **The ASSOCIATE arm**: `reply_success(sentinel)`, the sentinel
`127.0.0.1:0` BND.ADDR (pure RFC-compatibility surface; the `127.0.0.1:0` BND.ADDR (pure RFC-compatibility surface; the
channel is the relay handle, ADR-003) — then the datagram stage. channel is the relay handle, ADR-003), then the datagram stage.
2. **The datagram stage** — `select!`-driven (channel reads ∥ egress 2. **The datagram stage**, `select!`-driven (channel reads ∥ egress
replies), with the POC's three load-bearing findings as replies), with the POC's three load-bearing findings as
invariants: invariants:
- **The concurrent select shape** (not poll-then-block — the - **The concurrent select shape** (not poll-then-block, since the
poll-then-block window loses replies; the concurrent shape is poll-then-block window loses replies; the concurrent shape is
also the honest full-duplex shape). also the honest full-duplex shape).
- **The egress readiness contract**: `next_reply` is `Pending` - **The egress readiness contract**: `next_reply` is `Pending`
when nothing is ready — an instantly-ready `Ok(None)` starves a when nothing is ready; an instantly-ready `Ok(None)` starves a
biased select into livelock. **Write the contract test**: an biased select into livelock. **Write the contract test**: an
egress returning instant `Ok(None)` must not starve the stage's egress returning instant `Ok(None)` must not starve the stage's
channel branch (the POC's load-bearing API finding, re-pinned channel branch (the POC's load-bearing API finding, re-pinned
@@ -45,17 +45,17 @@ Deliverables (port from `/workspace/alksocks-udp-poc/src/producer.rs`
(`udp_undecodable_frame_ends_association`). (`udp_undecodable_frame_ends_association`).
3. **The `EgressFactory` lifecycle**: called once per ASSOCIATE; the 3. **The `EgressFactory` lifecycle**: called once per ASSOCIATE; the
stage owns the egress; stage exit drops it. Ownership rides the stage owns the egress; stage exit drops it. Ownership rides the
handle structurally — no `_` bindings of owning values (the POC's handle structurally: no `_` bindings of owning values (the POC's
teardown-at-birth witness). teardown-at-birth witness).
4. **Missing-egress refusal**: a registration without an 4. **Missing-egress refusal**: a registration without an
`EgressFactory` refuses ASSOCIATE RFC-faithfully `EgressFactory` refuses ASSOCIATE RFC-faithfully
(`CommandNotSupported`-class) — never a panic (ADR-008). (`CommandNotSupported`-class), never a panic (ADR-008).
5. **Tests** (via the harness + raw RFC client): associate → 5. **Tests** (via the harness + raw RFC client): associate →
sentinel reply → datagram round-trip through the echo egress sentinel reply → datagram round-trip through the echo egress
(in-order, multiple datagrams, one association); empty-payload (in-order, multiple datagrams, one association); empty-payload
datagram legal (not EOF); undecodable frame ends the association datagram legal (not EOF); undecodable frame ends the association
loud; channel EOF ends the association and the connection stays loud; channel EOF ends the association and the connection stays
healthy (a fresh associate succeeds immediately after — the POC's healthy (a fresh associate succeeds immediately after, the POC's
test 4); the readiness-contract test; missing-egress refusal; test 4); the readiness-contract test; missing-egress refusal;
CONNECT still served on the same registration (the regression CONNECT still served on the same registration (the regression
guard). guard).
@@ -86,14 +86,15 @@ Deliverables (port from `/workspace/alksocks-udp-poc/src/producer.rs`
## Notes ## Notes
- **Wire codec addition** (`src/wire.rs`): `frame_inner()` — the - **Wire codec addition** (`src/wire.rs`): `frame_inner()`, the
reply-direction half of the framing-once invariant (frames
reply-direction half of the framing-once invariant (frames reply-direction half of the framing-once invariant (frames
pre-encoded inner bytes from the egress; the forward-path pre-encoded inner bytes from the egress; the forward-path
`frame()` and the reply-path `frame_inner()` produce identical `frame()` and the reply-path `frame_inner()` produce identical
wires — pinned byte-for-byte). Exported from lib.rs. `DatagramReader` wires, pinned byte-for-byte). Exported from lib.rs. `DatagramReader`
and `frame()` were already in place from the wire-codec task. and `frame()` were already in place from the wire-codec task.
- **Stage shape** (`src/producer.rs`): `serve_udp_associate` drives - **Stage shape** (`src/producer.rs`): `serve_udp_associate` drives
the typestate directly (POC #2's shape — no `run_udp_proxy_custom`), the typestate directly (POC #2's shape, no `run_udp_proxy_custom`),
then `run_datagram_stage` is a `select!` loop over channel reads ∥ then `run_datagram_stage` is a `select!` loop over channel reads ∥
`egress.next_reply()`, returning a typed `StageExit` diagnostic `egress.next_reply()`, returning a typed `StageExit` diagnostic
(ChannelEof / UndecodableFrame / EgressEnded / ReplyFrameRejected / (ChannelEof / UndecodableFrame / EgressEnded / ReplyFrameRejected /
@@ -104,10 +105,10 @@ Deliverables (port from `/workspace/alksocks-udp-poc/src/producer.rs`
lifetime); undecodable frames (garbage ATYP, `len == 0`) and lifetime); undecodable frames (garbage ATYP, `len == 0`) and
egress `Err` end the association loud; a reply the joint bound egress `Err` end the association loud; a reply the joint bound
rejects (`ReplyFrameRejected`) is its own exit (never a wire rejects (`ReplyFrameRejected`) is its own exit (never a wire
overflow). The stage owns the egress structurally — the egress overflow). The stage owns the egress structurally: the egress
binding is moved into the stage call; every exit drops it binding is moved into the stage call; every exit drops it
(drop-witness test pins the teardown-at-birth discipline). (drop-witness test pins the teardown-at-birth discipline).
- **Missing-egress refusal**: `CommandNotSupported` (0x07) — the - **Missing-egress refusal**: `CommandNotSupported` (0x07), the
ADR-008 missing-callback class, same shape as CONNECT's ADR-008 missing-callback class, same shape as CONNECT's
missing-dial refusal. Egress-factory failure (post-command, missing-dial refusal. Egress-factory failure (post-command,
pre-reply) → reply#1 with the mapped RFC code pre-reply) → reply#1 with the mapped RFC code
@@ -121,7 +122,7 @@ Deliverables (port from `/workspace/alksocks-udp-poc/src/producer.rs`
- The consumer side (the `AssociateSession` mirror) is the - The consumer side (the `AssociateSession` mirror) is the
consumer-associate task; its `send_datagram` will frame via consumer-associate task; its `send_datagram` will frame via
`frame()` and its `recv_datagram` will decode via the persistent `frame()` and its `recv_datagram` will decode via the persistent
`DatagramReader` — both already exported. `DatagramReader`; both are already exported.
## Summary ## Summary
@@ -131,22 +132,22 @@ stage: sentinel reply (`127.0.0.1:0`, ADR-003's RFC-compatibility
surface) → the `select!`-driven stage (channel reads ∥ egress surface) → the `select!`-driven stage (channel reads ∥ egress
replies) with the three POC invariants as code: replies) with the three POC invariants as code:
1. **Concurrent select shape** — both branches of the loop are 1. **Concurrent select shape**: both branches of the loop are
awaited concurrently; no poll-then-block window (the reply-loss awaited concurrently; no poll-then-block window (the reply-loss
hazard the POC measured). hazard the POC measured).
2. **The egress readiness contract** — spurious `Ok(None)`s are 2. **The egress readiness contract**: spurious `Ok(None)`s are
tolerated by re-parking; the contract test (`udp_instant_ok_none_egress_does_not_starve_the_stage`) tolerated by re-parking; the contract test (`udp_instant_ok_none_egress_does_not_starve_the_stage`)
pins that an instantly-ready `Ok(None)` egress must not starve pins that an instantly-ready `Ok(None)` egress must not starve
the channel branch (the POC's load-bearing API finding, the channel branch (the POC's load-bearing API finding,
re-pinned by this suite). re-pinned by this suite).
3. **Framing applied exactly once, by the wire layer** — the stage 3. **Framing applied exactly once, by the wire layer**: the stage
reads frames via `DatagramReader` (egress `send` gets the bare reads frames via `DatagramReader` (egress `send` gets the bare
payload) and writes frames via the new `frame_inner` (egress payload) and writes frames via the new `frame_inner` (egress
`next_reply`'s inner bytes → the channel); the egress never sees `next_reply`'s inner bytes → the channel); the egress never sees
a length prefix. a length prefix.
Association lifetime = channel lifetime (EOF is the clean exit; Association lifetime = channel lifetime (EOF is the clean exit;
tested in-process and over channels — a fresh associate succeeds tested in-process and over channels; a fresh associate succeeds
immediately after, the POC's test 4). Missing egress refuses immediately after, the POC's test 4). Missing egress refuses
RFC-faithfully (0x07); egress-factory failure maps to reply#1's RFC-faithfully (0x07); egress-factory failure maps to reply#1's
code. CONNECT still serves on the same registration (regression code. CONNECT still serves on the same registration (regression
+25 -25
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/core-producer-bind id: implementation/core-producer-bind
name: "Core — producer BIND: the phase model + the socks5:bind command gate" name: "Core: producer BIND, the phase model + the socks5:bind command gate"
status: completed status: completed
depends_on: [implementation/core-producer-connect] depends_on: [implementation/core-producer-connect]
scope: moderate scope: moderate
@@ -21,27 +21,27 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
1. **The command gate**: at BIND dispatch, require `socks5:bind` 1. **The command gate**: at BIND dispatch, require `socks5:bind`
from the per-call opener identity (the open-op `AuthContext`'s from the per-call opener identity (the open-op `AuthContext`'s
identity — CF-005/CF-006). An identity without it gets the identity of the open-op `AuthContext`, CF-005/CF-006). An identity without it gets the
RFC-faithful `ConnectionNotAllowed` (0x02) refusal while CONNECT RFC-faithful `ConnectionNotAllowed` (0x02) refusal while CONNECT
still serves the same identity (POC #5 test 4). The gate is the still serves the same identity (POC #5 test 4). The gate is the
open-op identity seam consumed at command-dispatch time — no new open-op identity seam consumed at command-dispatch time: no new
ops, no params change. ops, no params change.
2. **The BIND phase model**: 2. **The BIND phase model**:
- **reply#1 carries BND.ADDR** — the `BindAcceptFactory` is - **reply#1 carries BND.ADDR**: the `BindAcceptFactory` is
called first; its `BindAccept.bind_addr` is reply#1's carrier called first; its `BindAccept.bind_addr` is reply#1's carrier
(the local variant's real listener; the sentinel on the (the local variant's real listener; the sentinel on the
wrapper-aware in-band path when no factory is wired — ADR-007 wrapper-aware in-band path when no factory is wired, per ADR-007
variant 3). variant 3).
- **The quiet wait**: `select!`-single-shot with exactly two - **The quiet wait**: `select!`-single-shot with exactly two
exits — the one inbound accept (the `BindAccept` future), or exits, the one inbound accept (the `BindAccept` future) or
the control stream's EOF/garbage/error (the client-gone the control stream's EOF/garbage/error (the client-gone
teardown riding the accept future's drop). **Not a loop** (the teardown riding the accept future's drop). **Not a loop** (the
POC's `never_loop` finding — exactly two exits). Any byte in POC's `never_loop` finding: exactly two exits). Any byte in
the quiet wait is protocol garbage → the session ends with no the quiet wait is protocol garbage → the session ends with no
data plane. data plane.
- **reply#2 carries the peer address** (the accepted handle's - **reply#2 carries the peer address** (the accepted handle's
peer; sentinel `0.0.0.0:0` wrapper-aware). peer; sentinel `0.0.0.0:0` wrapper-aware).
- **Phase 2 is raw pass-through** (`pump_bidi` inline — the - **Phase 2 is raw pass-through** (`pump_bidi` inline, the
CONNECT shape) between the channel and the accepted halves. CONNECT shape) between the channel and the accepted halves.
3. **Failure mapping is RFC-faithful end to end**: factory failure 3. **Failure mapping is RFC-faithful end to end**: factory failure
(pre-reply#1) → reply#1 with the mapped code; accept-stage (pre-reply#1) → reply#1 with the mapped code; accept-stage
@@ -49,22 +49,22 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
plane. No phantom listeners, no phantom data planes. plane. No phantom listeners, no phantom data planes.
4. **The establishment bound covers only the open-op round trip** 4. **The establishment bound covers only the open-op round trip**
(ADR-007): the accept wait lives in the teardown-governed pump (ADR-007): the accept wait lives in the teardown-governed pump
phase — the quiet wait must not run under the establisher's phase: the quiet wait must not run under the establisher's
10s bound (FTP-length BIND waits stop resolving as spurious 10s bound (FTP-length BIND waits stop resolving as spurious
establishment refusals). establishment refusals).
5. **Missing-accept refusal**: a registration without a 5. **Missing-accept refusal**: a registration without a
`BindAcceptFactory` serves the wrapper-aware in-band default `BindAcceptFactory` serves the wrapper-aware in-band default
(sentinel reply#1 + sentinel reply#2 — ADR-007 variant 3) or (sentinel reply#1 + sentinel reply#2, ADR-007 variant 3) or
refuses — the ADR names variant 3 as the in-band default when refuses; the ADR names variant 3 as the in-band default when
neither provider is wired; implement variant 3 as the neither provider is wired; implement variant 3 as the
no-factory default and document the choice. no-factory default and document the choice.
6. **Tests** (via the harness + raw RFC client): BIND → reply#1 → 6. **Tests** (via the harness + raw RFC client): BIND → reply#1 →
(the harness accept resolves) → reply#2 with the peer → (the harness accept resolves) → reply#2 with the peer →
pass-through echo both directions; the command gate (a pass-through echo both directions; the command gate (a
`socks5:open`-only identity gets 0x02 on BIND while CONNECT `socks5:open`-only identity gets 0x02 on BIND while CONNECT
still works — the POC's test 4); quiet-wait garbage byte ends still works, the POC's test 4); quiet-wait garbage byte ends
the session with no data plane; client-gone teardown (EOF the session with no data plane; client-gone teardown (EOF
mid-wait drops the accept future — a fresh dial to the mid-wait drops the accept future, so a fresh dial to the
advertised address fails); factory failure → reply#1 mapped advertised address fails); factory failure → reply#1 mapped
code; accept-stage failure → reply#2 mapped code, no data plane; code; accept-stage failure → reply#2 mapped code, no data plane;
CONNECT + ASSOCIATE regression guard (one registration, all CONNECT + ASSOCIATE regression guard (one registration, all
@@ -97,32 +97,32 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
## Notes ## Notes
- **The gate seam**: `serve_socks5_over<T>` gained a `can_bind: bool` - **The gate seam**: `serve_socks5_over<T>` gained a `can_bind: bool`
parameter — the pump handler resolves it from the open-op parameter: the pump handler resolves it from the open-op
`AuthContext`'s per-call opener identity (CF-005/CF-006: the `AuthContext`'s per-call opener identity (CF-005/CF-006: the
`socks5:bind` scope, never implied by `socks5:open`); the `socks5:bind` scope, never implied by `socks5:open`); the
substrate-free entry takes the resolved boolean explicitly, so the substrate-free entry takes the resolved boolean explicitly, so the
gate is caller-policy at the raw-entry shape and gate is caller-policy at the raw-entry shape and
identity-resolved at the channels shape. A scope-less BIND gets identity-resolved at the channels shape. A scope-less BIND gets
`ConnectionNotAllowed` (0x02) with no factory call (the gate `ConnectionNotAllowed` (0x02) with no factory call (the gate
precedes the seam — no phantom listener), while CONNECT still precedes the seam, so no phantom listener), while CONNECT still
serves the same identity (tested unit + over-channels). serves the same identity (tested unit + over-channels).
- **Variant 3 as the no-factory default** (documented in - **Variant 3 as the no-factory default** (documented in
`serve_tcp_bind`'s docs): when no `BindAcceptFactory` is wired, the `serve_tcp_bind`'s docs): when no `BindAcceptFactory` is wired, the
BIND arm synthesizes a `BindAccept` — the sentinel reply#1 (via BIND arm synthesizes a `BindAccept`: the sentinel reply#1 (via
`sentinel_bind_peer_addr()` = `0.0.0.0:0` riding the accept `sentinel_bind_peer_addr()` = `0.0.0.0:0` riding the accept
future's instant resolution, so the full phase model is preserved: future's instant resolution, so the full phase model is preserved:
reply#1 → quiet wait → reply#2 → pass-through) + the sentinel reply#1 → quiet wait → reply#2 → pass-through) + the sentinel
reply#2 peer. The channel is the connection (the handle-swap reply#2 peer. The channel is the connection (the handle-swap
reading); the in-band halves are `tokio::io::empty`/`sink` — the reading); the in-band halves are `tokio::io::empty`/`sink`, and the
data plane exists and EOFs. data plane exists and EOFs.
- **The quiet wait** is a single-shot `select!` with exactly two - **The quiet wait** is a single-shot `select!` with exactly two
exits (`bind_quiet_wait_and_serve`): the accept branch (its drop IS exits (`bind_quiet_wait_and_serve`): the accept branch (its drop IS
the client-gone teardown — verified by an owner-guard test), or the the client-gone teardown, verified by an owner-guard test) or the
control stream's read (EOF = clean end, any byte = protocol control stream's read (EOF = clean end, any byte = protocol
garbage → `UnexpectedUdpControlGarbage`, no reply#2, no data garbage → `UnexpectedUdpControlGarbage`, no reply#2, no data
plane). Not a loop (the POC's `never_loop` finding). plane). Not a loop (the POC's `never_loop` finding).
- **Reply#2** uses the fork's published `new_reply` (carried change - **Reply#2** uses the fork's published `new_reply` (carried change
#2's payoff — no local re-encode); the peer address carries as #2's payoff: no local re-encode); the peer address carries as
`TargetAddr::Ip` → the real socket addr, domain-form peer → v4 `TargetAddr::Ip` → the real socket addr, domain-form peer → v4
unspecified with the port. unspecified with the port.
- **Failure mapping**: factory failure → reply#1's mapped code, then - **Failure mapping**: factory failure → reply#1's mapped code, then
@@ -130,7 +130,7 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
CONNECT's dial shape); accept-stage failure → reply#2's mapped code, CONNECT's dial shape); accept-stage failure → reply#2's mapped code,
then `Bug("bind accept refused in-stream")`. No phantom listeners, no then `Bug("bind accept refused in-stream")`. No phantom listeners, no
phantom data planes. phantom data planes.
- **Establishment bound**: structurally satisfied — the quiet wait - **Establishment bound**: structurally satisfied. The quiet wait
runs inside the pump handler's spawned task, which the alkcall open runs inside the pump handler's spawned task, which the alkcall open
wrapper never bounds (the establisher's 10s bound is pre-handler); wrapper never bounds (the establisher's 10s bound is pre-handler);
the harness round-trip + the substrate-free "parked accept still the harness round-trip + the substrate-free "parked accept still
@@ -138,9 +138,9 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
- **Test determinism**: the quiet-wait tests use a parked accept - **Test determinism**: the quiet-wait tests use a parked accept
factory (`std::future::pending`) so the garbage/EOF branch wins factory (`std::future::pending`) so the garbage/EOF branch wins
deterministically (an instantly-resolving accept races the garbage deterministically (an instantly-resolving accept races the garbage
branch — the client-gone test's owner flag witnesses the future's branch, and the client-gone test's owner flag witnesses the future's
drop). drop).
- **Harness additions**: `bind_scoped_identity()` (both scopes — a - **Harness additions**: `bind_scoped_identity()` (both scopes, since a
session needs the open gate AND the command gate) + BIND session needs the open gate AND the command gate) + BIND
integration tests: round-trip over channels, the gate over integration tests: round-trip over channels, the gate over
channels, both-scopes identity serves, quiet-wait garbage over channels, both-scopes identity serves, quiet-wait garbage over
@@ -156,9 +156,9 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
## Summary ## Summary
Implemented the TCP BIND producer path (`src/producer.rs`): the Implemented the TCP BIND producer path (`src/producer.rs`): the
`socks5:bind` command gate (ADR-006 — resolved from the open-op `socks5:bind` command gate (ADR-006, resolved from the open-op
identity at the pump handler, passed as `can_bind` to the identity at the pump handler, passed as `can_bind` to the
substrate-free entry), the phase model (ADR-007 / POC #5 — accept substrate-free entry), the phase model (ADR-007 / POC #5: accept
factory → reply#1 carrying BND.ADDR → the single-shot quiet wait factory → reply#1 carrying BND.ADDR → the single-shot quiet wait
(two exits; any byte = garbage, EOF = client-gone teardown riding the (two exits; any byte = garbage, EOF = client-gone teardown riding the
accept future's drop) → reply#2 carrying the peer → `pump_bidi` accept future's drop) → reply#2 carrying the peer → `pump_bidi`
+18 -18
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/core-producer-connect id: implementation/core-producer-connect
name: "Core — producer half: establisher + dispatch + registration + CONNECT" name: "Core: producer half, establisher + dispatch + registration + CONNECT"
status: completed status: completed
depends_on: [implementation/core-seam-types, implementation/core-params-errors, implementation/core-test-harness] depends_on: [implementation/core-seam-types, implementation/core-params-errors, implementation/core-test-harness]
scope: moderate scope: moderate
@@ -14,7 +14,7 @@ level: implementation
Implement the producer half of `src/producer.rs` per producer.md: Implement the producer half of `src/producer.rs` per producer.md:
the session-gate establisher, the command-dispatch pump handler, the the session-gate establisher, the command-dispatch pump handler, the
registration surface, and the CONNECT data plane (POC #1's validated registration surface, and the CONNECT data plane (POC #1's validated
scope). This is the crate's core task — the open op all three scope). This is the crate's core task: the open op all three
commands ride. commands ride.
Deliverables (port from `/workspace/alksocks-connect-poc/src/ Deliverables (port from `/workspace/alksocks-connect-poc/src/
@@ -22,9 +22,9 @@ producer.rs` + `/workspace/alksocks-udp-poc/src/producer.rs`, adapted
to the crate's seam types and alkcall 0.8.0): to the crate's seam types and alkcall 0.8.0):
1. **The session-gate establisher** (`socks5_session_establisher`): 1. **The session-gate establisher** (`socks5_session_establisher`):
validates/records the opener identity (the CF-005/CF-006 seam — validates/records the opener identity (the CF-005/CF-006 seam in
ADR-005) and applies session-level policy. Runs ADR-005) and applies session-level policy. Runs
`Establishment::default()`-shaped (no plan payload — the dial `Establishment::default()`-shaped (no plan payload; the dial
happens post-establishment, so there is nothing to thread happens post-establishment, so there is nothing to thread
establisher→handler; POC #1's wire note). Optional establisher→handler; POC #1's wire note). Optional
`identity_witness` probe seam (the alktunnels posture) for tests. `identity_witness` probe seam (the alktunnels posture) for tests.
@@ -33,17 +33,17 @@ to the crate's seam types and alkcall 0.8.0):
`BiStream` (`accept_no_auth → read_command`) → dispatch: `BiStream` (`accept_no_auth → read_command`) → dispatch:
- **CONNECT** → invoke `DialFn(target)` at the command-read - **CONNECT** → invoke `DialFn(target)` at the command-read
interception point; success: `reply_success` then `pump_bidi` interception point; success: `reply_success` then `pump_bidi`
**awaited inline** (R-02 — the returned `JoinHandle` tracks the **awaited inline** (R-02: the returned `JoinHandle` tracks the
data-plane lifetime; early return = teardown-at-birth). Refusal: data-plane lifetime; early return = teardown-at-birth). Refusal:
`reply_error` with the mapped RFC code (dial-failure → `reply_error` with the mapped RFC code (dial-failure →
`ConnectionRefused`; scope-class → `ConnectionNotAllowed`) — no `ConnectionRefused`; scope-class → `ConnectionNotAllowed`), no
phantom data plane. phantom data plane.
- **UDP ASSOCIATE / TCP BIND** → this task lands the dispatch - **UDP ASSOCIATE / TCP BIND** → this task lands the dispatch
arms as RFC-faithful refusals (`CommandNotSupported`); the arms as RFC-faithful refusals (`CommandNotSupported`); the
associate/bind producer tasks replace them. (The dispatch associate/bind producer tasks replace them. (The dispatch
skeleton exists from day one so the regression guard — one skeleton exists from day one so the regression guard (one
registration, all three commands — is testable early.) registration, all three commands) is testable early.)
- The handler is substrate-agnostic by construction — it sees - The handler is substrate-agnostic by construction: it sees
boxed halves, never a socket type. boxed halves, never a socket type.
3. **`register_socks5_openable`**: spec + establisher + pump via 3. **`register_socks5_openable`**: spec + establisher + pump via
`register_openable_with_establisher` (`timeout: None` = the 10s `register_openable_with_establisher` (`timeout: None` = the 10s
@@ -51,7 +51,7 @@ to the crate's seam types and alkcall 0.8.0):
before the establisher). Missing callbacks = those commands before the establisher). Missing callbacks = those commands
refuse RFC-faithfully (`CommandNotSupported`-class), never a refuse RFC-faithfully (`CommandNotSupported`-class), never a
panic (ADR-008). panic (ADR-008).
4. **`serve_socks5_over<T>`** — the substrate-free protocol entry 4. **`serve_socks5_over<T>`**: the substrate-free protocol entry
point (the POC shape): generic over `T: AsyncRead + AsyncWrite + point (the POC shape): generic over `T: AsyncRead + AsyncWrite +
Unpin`, driving the typestate and dispatching. This is what makes Unpin`, driving the typestate and dispatching. This is what makes
the producer testable without channels (the differential-suite the producer testable without channels (the differential-suite
@@ -97,7 +97,7 @@ to the crate's seam types and alkcall 0.8.0):
> Implementation notes (2026-09-23): > Implementation notes (2026-09-23):
> >
> - `Socks5ProducerBuilder` was added beyond the deliverable list — > - `Socks5ProducerBuilder` was added beyond the deliverable list:
> it is the spec'd assembly shape (producer.md §Wiring, the > it is the spec'd assembly shape (producer.md §Wiring, the
> `Socks5Producer::builder()` sketch); `register_socks5_openable` > `Socks5Producer::builder()` sketch); `register_socks5_openable`
> stays as the explicit per-registration form. Both ride the same > stays as the explicit per-registration form. Both ride the same
@@ -105,15 +105,15 @@ to the crate's seam types and alkcall 0.8.0):
> `register`/`serve_socks5_over` delegate to them verbatim. > `register`/`serve_socks5_over` delegate to them verbatim.
> - The `establisher` accepts `Option<IdentityWitness>` (the > - The `establisher` accepts `Option<IdentityWitness>` (the
> alktunnels `*_with_witness` posture) rather than a required > alktunnels `*_with_witness` posture) rather than a required
> witness — the witness is a test seam, not a protocol surface; > witness, because the witness is a test seam, not a protocol surface;
> `register_socks5_openable` threads it through. > `register_socks5_openable` threads it through.
> - The ASSOCIATE arm carries the `EgressFactory` parameter (and the > - The ASSOCIATE arm carries the `EgressFactory` parameter (and the
> BIND arm the `BindAcceptFactory`) in the registration signature > BIND arm the `BindAcceptFactory`) in the registration signature
> from day one — the associate/bind tasks replace the refusal arms > from day one, so the associate/bind tasks replace the refusal arms
> without touching the registration shape (no churn for the > without touching the registration shape (no churn for the
> callers). Until those tasks land, the seams are accepted and > callers). Until those tasks land, the seams are accepted and
> their commands refuse `CommandNotSupported` (the pre-vertical > their commands refuse `CommandNotSupported` (the pre-vertical
> posture, ADR-008's missing-callback class) — tested. > posture, ADR-008's missing-callback class), tested.
> - CONNECT's refusal error shape: dial-seam refusal returns > - CONNECT's refusal error shape: dial-seam refusal returns
> `SocksServerError::Bug("dial refused in-stream")` after > `SocksServerError::Bug("dial refused in-stream")` after
> `reply_error` (the refusal was already RFC-reported; the error > `reply_error` (the refusal was already RFC-reported; the error
@@ -124,10 +124,10 @@ to the crate's seam types and alkcall 0.8.0):
## Summary ## Summary
> Completed 2026-09-23 (commit on `main`). `src/producer.rs` filled: > Completed 2026-09-23 (commit on `main`). `src/producer.rs` filled:
> the session-gate establisher ([`socks5_session_establisher`] — > the session-gate establisher ([`socks5_session_establisher`];
> CF-005/CF-006 witness seam, `Establishment::default()` per POC #1's > CF-005/CF-006 witness seam, `Establishment::default()` per POC #1's
> no-plan wire note), the command-dispatch pump handler > no-plan wire note), the command-dispatch pump handler
> ([`make_socks5_pump_handler`] — `accept_bi` → typestate > ([`make_socks5_pump_handler`]: `accept_bi` → typestate
> (`accept_no_auth → read_command`) → dispatch), the CONNECT data > (`accept_no_auth → read_command`) → dispatch), the CONNECT data
> plane (`DialFn` at the command-read interception point → > plane (`DialFn` at the command-read interception point →
> `reply_success(sentinel)` → `pump_bidi` awaited inline, R-02), > `reply_success(sentinel)` → `pump_bidi` awaited inline, R-02),
@@ -137,7 +137,7 @@ to the crate's seam types and alkcall 0.8.0):
> substrate-free entry), and the builder assembly shape. > substrate-free entry), and the builder assembly shape.
> >
> Dispatch arms: CONNECT serves; ASSOCIATE/BIND refuse > Dispatch arms: CONNECT serves; ASSOCIATE/BIND refuse
> `CommandNotSupported` (the pre-vertical posture — the skeleton > `CommandNotSupported` (the pre-vertical posture; the skeleton
> exists so the regression guard is testable early; the associate/ > exists so the regression guard is testable early; the associate/
> bind tasks replace the arms). > bind tasks replace the arms).
> >
@@ -148,7 +148,7 @@ to the crate's seam types and alkcall 0.8.0):
> shape) + 5 harness integration (end-to-end CONNECT echo through > shape) + 5 harness integration (end-to-end CONNECT echo through
> channels with the identity witness, dial refusal 0x05 with no > channels with the identity witness, dial refusal 0x05 with no
> phantom data plane, scope-class refusal 0x02, ASSOCIATE/BIND > phantom data plane, scope-class refusal 0x02, ASSOCIATE/BIND
> refusals with CONNECT still serving — the one-registration > refusals with CONNECT still serving (the one-registration
> regression guard, repeated CONNECTs on one registration). The > regression guard, repeated CONNECTs on one registration). The
> harness's `RegisterMode` gained the real-producer mode; the > harness's `RegisterMode` gained the real-producer mode; the
> minimal CONNECT stand-in is retained only as a harness-internal > minimal CONNECT stand-in is retained only as a harness-internal
+25 -25
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/core-seam-types id: implementation/core-seam-types
name: Core — the three seam types (DialFn / Egress / BindAccept families) name: "Core: the three seam types (DialFn / Egress / BindAccept families)"
status: completed status: completed
depends_on: [implementation/core-crate-deps] depends_on: [implementation/core-crate-deps]
scope: moderate scope: moderate
@@ -14,50 +14,50 @@ level: implementation
Implement `src/egress.rs` per producer.md §The Three Seams and Implement `src/egress.rs` per producer.md §The Three Seams and
ADR-004: the three I/O-plane seam types as function-shaped callbacks ADR-004: the three I/O-plane seam types as function-shaped callbacks
with their error vocabularies. The protocol layer never names a with their error vocabularies. The protocol layer never names a
substrate type — these types are the inversion point; the batch-2 substrate type. These types are the inversion point; the batch-2
providers (local/composed) and the test harness all construct them. providers (local/composed) and the test harness all construct them.
Deliverables (port from the POCs — the shapes are POC-validated): Deliverables (port from the POCs; the shapes are POC-validated):
1. **The dial seam (CONNECT)** — from 1. **The dial seam (CONNECT)**, from
`/workspace/alksocks-connect-poc/src/producer.rs` + `/workspace/alksocks-connect-poc/src/producer.rs` +
`/workspace/alksocks-udp-poc/src/producer.rs`: `/workspace/alksocks-udp-poc/src/producer.rs`:
- `TargetHandle { read, write }` — boxed halves - `TargetHandle { read, write }`, i.e. boxed halves
(`Box<dyn AsyncRead + Unpin + Send>`, (`Box<dyn AsyncRead + Unpin + Send>`,
`Box<dyn AsyncWrite + Unpin + Send>`; `+ Sync` per the `Box<dyn AsyncWrite + Unpin + Send>`; `+ Sync` per the
ChannelPlan payload bound). Ownership rides the handle ChannelPlan payload bound). Ownership rides the handle
structurally (ADR-004). structurally (ADR-004).
- `DialFn` — `Arc<dyn Fn(TargetAddr) -> BoxFuture<'static, - `DialFn`: `Arc<dyn Fn(TargetAddr) -> BoxFuture<'static,
Result<TargetHandle, SocksDialError>> + Send + Sync>`. Result<TargetHandle, SocksDialError>> + Send + Sync>`.
- `SocksDialError` with faithful `to_reply_error()` mapping - `SocksDialError` with faithful `to_reply_error()` mapping
(refused → `ConnectionRefused`, not-allowed → (refused → `ConnectionRefused`, not-allowed →
`ConnectionNotAllowed`, unreachable → `NetworkUnreachable`, `ConnectionNotAllowed`, unreachable → `NetworkUnreachable`,
general → `GeneralFailure` — the POC's mapping, RFC-faithful general → `GeneralFailure` (the POC's mapping, RFC-faithful
per AGENTS.md convention 2). per AGENTS.md convention 2).
2. **The egress seam (ASSOCIATE)** — from 2. **The egress seam (ASSOCIATE)**, from
`/workspace/alksocks-udp-poc/src/egress.rs`: `/workspace/alksocks-udp-poc/src/egress.rs`:
- `Egress` trait: `send(addr, payload)` (payload-only — the frame - `Egress` trait: `send(addr, payload)` (payload-only; the frame
codec lives on the wire side) + `next_reply()` returning *inner* codec lives on the wire side) + `next_reply()` returning *inner*
bytes (`addr + data`). bytes (`addr + data`).
- **The readiness contract is load-bearing API surface** (POC #2's - **The readiness contract is load-bearing API surface** (POC #2's
structural finding): `next_reply` must be `Pending` when no structural finding): `next_reply` must be `Pending` when no
reply is ready — an instantly-ready `Ok(None)` starves a biased reply is ready: an instantly-ready `Ok(None)` starves a biased
select into livelock. Document it on the trait; the contract select into livelock. Document it on the trait; the contract
test lands with the producer-associate task. test lands with the producer-associate task.
- `EgressFactory` — called once per ASSOCIATE; the stage owns the - `EgressFactory`: called once per ASSOCIATE; the stage owns the
egress; stage exit drops it. egress; stage exit drops it.
- `EgressError` with `to_reply_error()` mapping. - `EgressError` with `to_reply_error()` mapping.
3. **The accept seam (BIND)** — from 3. **The accept seam (BIND)**, from
`/workspace/alksocks-bind-poc/src/accept.rs`: `/workspace/alksocks-bind-poc/src/accept.rs`:
- `BindAccept { bind_addr: SocketAddr, accept: BoxFuture<'static, - `BindAccept { bind_addr: SocketAddr, accept: BoxFuture<'static,
Result<BindTargetHandle, BindAcceptError>> }` — reply#1's Result<BindTargetHandle, BindAcceptError>> }`: reply#1's
BND.ADDR carrier + the one-inbound-accept future. **The accept BND.ADDR carrier + the one-inbound-accept future. **The accept
future owns its resources end-to-end**; the quiet wait's future owns its resources end-to-end**; the quiet wait's
`select!` drop IS the client-gone teardown (ADR-004/ADR-007). `select!` drop IS the client-gone teardown (ADR-004/ADR-007).
- `BindTargetHandle` — accepted halves + peer address; ownership - `BindTargetHandle`: accepted halves + peer address; ownership
rides the handle structurally (the third teardown-at-birth rides the handle structurally (the third teardown-at-birth
witness — no `_` bindings of owning values). witness: no `_` bindings of owning values).
- `BindAcceptFactory` — called once per BIND with the requested - `BindAcceptFactory`: called once per BIND with the requested
target. target.
- `BindAcceptError` (accept-stage, post-reply#1 → reply#2) and - `BindAcceptError` (accept-stage, post-reply#1 → reply#2) and
`BindAcceptFactoryError` (pre-reply#1 → reply#1), each with `BindAcceptFactoryError` (pre-reply#1 → reply#1), each with
@@ -103,7 +103,7 @@ with the producer-associate task (it needs the stage).
- `src/egress.rs`: all three seam families per the POC shapes. - `src/egress.rs`: all three seam families per the POC shapes.
- **Dial (CONNECT)**: `TargetHandle {read, write}` (boxed halves - **Dial (CONNECT)**: `TargetHandle {read, write}` (boxed halves
`Send + Sync + Unpin` — the `+ Sync` per the `ChannelPlan` payload `Send + Sync + Unpin`, with the `+ Sync` per the `ChannelPlan` payload
bound), `DialFn` (`Arc<dyn Fn(TargetAddr) -> BoxFuture<'static, bound), `DialFn` (`Arc<dyn Fn(TargetAddr) -> BoxFuture<'static,
Result<TargetHandle, SocksDialError>> + Send + Sync>`), and Result<TargetHandle, SocksDialError>> + Send + Sync>`), and
`SocksDialError` with the POC #1 five-variant mapping `SocksDialError` with the POC #1 five-variant mapping
@@ -126,16 +126,16 @@ with the producer-associate task (it needs the stage).
planned. planned.
- **Accept (BIND)**: `BindAccept {bind_addr, accept}` (the - **Accept (BIND)**: `BindAccept {bind_addr, accept}` (the
`accept()` consumer awaits the boxed future exactly once; the `accept()` consumer awaits the boxed future exactly once; the
future owns its resources end-to-end — the quiet wait's drop IS future owns its resources end-to-end; the quiet wait's drop IS
the client-gone teardown), `BindTargetHandle {peer, read, write}` the client-gone teardown), `BindTargetHandle {peer, read, write}`
(ownership structural; no `_` owner field — keep-alive state (ownership structural; no `_` owner field, since keep-alive state
rides inside the halves as a provider-side owner wrapper, which rides inside the halves as a provider-side owner wrapper, which
is the substrate-agnostic way to carry the POC's `_session` is the substrate-agnostic way to carry the POC's `_session`
without naming a substrate type in the protocol layer), without naming a substrate type in the protocol layer),
`BindAcceptFactory`, and the two-stage error pair `BindAcceptFactory`, and the two-stage error pair
(`BindAcceptFactoryError` pre-reply#1 → reply#1; (`BindAcceptFactoryError` pre-reply#1 → reply#1;
`BindAcceptError` post-reply#1 → reply#2; `Shortage`→ `BindAcceptError` post-reply#1 → reply#2; `Shortage`→
`GeneralFailure` — no dedicated RFC code for shortage). `GeneralFailure` (there is no dedicated RFC code for shortage).
- No trait for the seams themselves (ADR-004); the re-evaluation - No trait for the seams themselves (ADR-004); the re-evaluation
threshold (three real trait impls) is stated in the module docs. threshold (three real trait impls) is stated in the module docs.
- No substrate type named anywhere in the module (grep-verified: - No substrate type named anywhere in the module (grep-verified:
@@ -146,10 +146,10 @@ with the producer-associate task (it needs the stage).
need it in scope). need it in scope).
- Deviations from the POC code, all deliberate: `BindTargetHandle` - Deviations from the POC code, all deliberate: `BindTargetHandle`
drops the POC's `_session: Option<TunnelSession>` field (substrate drops the POC's `_session: Option<TunnelSession>` field (substrate
types cannot appear here — ownership rides the halves; see the types cannot appear here; ownership rides the halves (see the
dial bullet), `BindAccept` exposes `bind_addr()` as a getter dial bullet), `BindAccept` exposes `bind_addr()` as a getter
instead of a public field (the accept future is already private — instead of a public field (the accept future is already private,
the struct now encapsulates both), and the test halves use so the struct now encapsulates both), and the test halves use
`tokio::io::duplex` (test-only; the POCs' `std::io` empty/sink do `tokio::io::duplex` (test-only; the POCs' `std::io` empty/sink do
not implement tokio's traits). not implement tokio's traits).
@@ -157,10 +157,10 @@ with the producer-associate task (it needs the stage).
> Filled on completion (2026-09-23). > Filled on completion (2026-09-23).
Implemented `src/egress.rs` — the three I/O-plane seam types (ADR-004), Implemented `src/egress.rs`, the three I/O-plane seam types (ADR-004),
the protocol layer's inversion point: the dial seam (`TargetHandle` the protocol layer's inversion point: the dial seam (`TargetHandle`
boxed halves, `DialFn`, `SocksDialError` with the five-variant boxed halves, `DialFn`, `SocksDialError` with the five-variant
RFC-faithful mapping), the egress seam (the one trait, `Egress` — RFC-faithful mapping), the egress seam (the one trait, `Egress`:
`send` payload-only, `next_reply` returning inner bytes with the `send` payload-only, `next_reply` returning inner bytes with the
load-bearing readiness contract documented in rustdoc; `EgressFactory` load-bearing readiness contract documented in rustdoc; `EgressFactory`
+ the `egress_factory` sync-lift constructor; `EgressError`), and the + the `egress_factory` sync-lift constructor; `EgressError`), and the
+39 -38
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/core-test-harness id: implementation/core-test-harness
name: Core — integration test harness (duplex transport + identities + in-process seams) name: "Core: integration test harness (duplex transport + identities + in-process seams)"
status: completed status: completed
depends_on: [implementation/core-seam-types, implementation/core-params-errors] depends_on: [implementation/core-seam-types, implementation/core-params-errors]
scope: moderate scope: moderate
@@ -11,8 +11,8 @@ level: implementation
## Description ## Description
Build `tests/harness.rs` — the producer↔consumer wiring over a Build `tests/harness.rs`, the producer↔consumer wiring over a
`tokio::io::duplex` carrying the channels 8-byte chunk format — so `tokio::io::duplex` carrying the channels 8-byte chunk format, so
every subsequent core task tests end-to-end without the `local` every subsequent core task tests end-to-end without the `local`
feature. This is the alkcall-0.8.0-first-consumption task: the POCs feature. This is the alkcall-0.8.0-first-consumption task: the POCs
ran 0.7.1; the harness proves the 0.8.0 establishment surface ran 0.7.1; the harness proves the 0.8.0 establishment surface
@@ -20,78 +20,78 @@ ran 0.7.1; the harness proves the 0.8.0 establishment surface
`ChannelClient::open_channel`) works for this crate's shape before `ChannelClient::open_channel`) works for this crate's shape before
the producer task builds on it. Template: the producer task builds on it. Template:
`/workspace/@alkdev/alktunnels/tests/harness.rs` (the sibling's `/workspace/@alkdev/alktunnels/tests/harness.rs` (the sibling's
harness — same topology, adapted to this crate's seams). harness, same topology, adapted to this crate's seams).
Topology (the alktunnels harness shape): Topology (the alktunnels harness shape):
- **producer** (connect side of the channels connection — runs - **producer** (connect side of the channels connection; runs
`ChannelClient::from_connection_with_serving`): serves the socks5 `ChannelClient::from_connection_with_serving`): serves the socks5
open op + generic channel ops on its serving registry. Its manager open op + generic channel ops on its serving registry. Its manager
allocates the channel IDs (`ChannelSide::Connect` — the serving allocates the channel IDs (`ChannelSide::Connect`, since the serving
side allocates; the initiator adopts). side allocates and the initiator adopts).
- **consumer** (accept side): the `ChannelsAdapter` + capturing - **consumer** (accept side): the `ChannelsAdapter` + capturing
install hook. The hook captures the consumer's channel-0 install hook. The hook captures the consumer's channel-0
`CallConnection` and `ChannelManager`. `CallConnection` and `ChannelManager`.
- **The POC #1 wiring requirement is load-bearing here** (producer.md - **The POC #1 wiring requirement is load-bearing here** (producer.md
§Harness wiring requirement): the accept-side install hook must §Harness wiring requirement): the accept-side install hook must
call `channel0_conn.set_identity(resolved)` before building the call `channel0_conn.set_identity(resolved)` before building the
`CallConnection` — without it, scope-gated opens fail closed with `CallConnection`; without it, scope-gated opens fail closed with
`FORBIDDEN "authentication required"` (correct behavior, implicit `FORBIDDEN "authentication required"` (correct behavior, implicit
wiring). The harness makes the requirement explicit and tested. wiring). The harness makes the requirement explicit and tested.
Deliverables: Deliverables:
1. **Identities**: `consumer_identity()` (`socks5:open` only), 1. **Identities**: `consumer_identity()` (`socks5:open` only),
`bind_identity()` (`socks5:bind` only — the command-gate probe), `bind_identity()` (`socks5:bind` only, the command-gate probe),
`both_scopes_identity()` (both grants, explicitly — never implied, `both_scopes_identity()` (both grants, explicitly and never implied,
ADR-006), plus a `TestIdProvider` resolving them (the alktunnels ADR-006), plus a `TestIdProvider` resolving them (the alktunnels
harness shape). harness shape).
2. **In-process seam closures** (no sockets — the protocol layer is 2. **In-process seam closures** (no sockets; the protocol layer is
substrate-agnostic, so the harness dials in-process): substrate-agnostic, so the harness dials in-process):
- `echo_dial()` — a `DialFn` returning duplex-halves wired to an - `echo_dial()`: a `DialFn` returning duplex-halves wired to an
in-process echo server (the POC #1 shape); plus in-process echo server (the POC #1 shape); plus
`refusing_dial()` (a failing dial for the refusal-mapping `refusing_dial()` (a failing dial for the refusal-mapping
tests). tests).
- `echo_egress()` — an `EgressFactory` whose egress echoes - `echo_egress()`: an `EgressFactory` whose egress echoes
datagrams back (the POC #2 shape, readiness-contract-clean: datagrams back (the POC #2 shape, readiness-contract-clean:
parks on a channel `recv()`, `Pending` when nothing is ready). parks on a channel `recv()`, `Pending` when nothing is ready).
- `echo_accept()` — a `BindAcceptFactory` whose accept resolves - `echo_accept()`: a `BindAcceptFactory` whose accept resolves
immediately with in-process halves (the POC #5 shape); plus a immediately with in-process halves (the POC #5 shape); plus a
failing variant. failing variant.
3. **The producer registration helper**: a `spawn_producer(...)` 3. **The producer registration helper**: a `spawn_producer(...)`
function wiring `register_socks5_openable` (or its task-6 function wiring `register_socks5_openable` (or its task-6
successor) with the harness seams — the single place the successor) with the harness seams, the single place the
producer-side wiring lives, so the per-command tasks only write producer-side wiring lives, so the per-command tasks only write
tests. tests.
4. **A raw RFC client helper**: a hand-rolled minimal RFC 1928 4. **A raw RFC client helper**: a hand-rolled minimal RFC 1928
client over any `AsyncRead + AsyncWrite` (greeting → method client over any `AsyncRead + AsyncWrite` (greeting → method
selection → command with target → reply read) — the differential selection → command with target → reply read) following the differential
suite's established pattern — so producer tasks test without suite's established pattern, so producer tasks test without
depending on the consumer session types (which land later). depending on the consumer session types (which land later).
5. **One smoke test** proving the full loop: harness up → producer 5. **One smoke test** proving the full loop: harness up → producer
registered → consumer opens a channel → raw RFC CONNECT registered → consumer opens a channel → raw RFC CONNECT
conversation → echo round-trip. (This test needs a minimal conversation → echo round-trip. (This test needs a minimal
CONNECT producer path; if the producer task hasn't landed, stub CONNECT producer path; if the producer task hasn't landed, stub
the handler with the POC #1 `serve_socks5_over` shape inline in the handler with the POC #1 `serve_socks5_over` shape inline in
the harness — the producer task then replaces it. Alternatively the harness, and the producer task then replaces it. Alternatively
sequence this task to land immediately before the producer task sequence this task to land immediately before the producer task
and let the smoke test be the producer task's first test — the and let the smoke test be the producer task's first test; the
implementer's judgment; the acceptance bar is the harness implementer's judgment; the acceptance bar is the harness
compiling with the identities + seams + helpers tested.) compiling with the identities + seams + helpers tested.)
## Acceptance Criteria ## Acceptance Criteria
- [x] Harness topology works over alkcall 0.8.0 (the establishment - [x] Harness topology works over alkcall 0.8.0 (the establishment
surface consumed successfully — the first-consumption risk surface consumed successfully; the first-consumption risk
retired) retired)
- [x] Identity wiring explicit: `set_identity` in the install hook; - [x] Identity wiring explicit: `set_identity` in the install hook;
a scope-less identity gets `FORBIDDEN` (fails closed) — tested a scope-less identity gets `FORBIDDEN` (fails closed), tested
- [x] All three identities + all in-process seam closures unit-tested - [x] All three identities + all in-process seam closures unit-tested
- [x] The raw RFC client helper round-trips a greeting + command + - [x] The raw RFC client helper round-trips a greeting + command +
reply against a hand-rolled server over a duplex reply against a hand-rolled server over a duplex
- [x] No `local` feature, no sockets — everything in-process - [x] No `local` feature, no sockets; everything in-process
- [x] `cargo test` green; clippy/fmt clean; both wasm checks clean - [x] `cargo test` green; clippy/fmt clean; both wasm checks clean
(the harness is dev-only — verify it stays out of the wasm (the harness is dev-only; verify it stays out of the wasm
build) build)
## References ## References
@@ -112,7 +112,7 @@ Deliverables:
- `tests/harness.rs`: the producer↔consumer wiring over one - `tests/harness.rs`: the producer↔consumer wiring over one
`tokio::io::duplex` carrying the channels 8-byte chunk format, in `tokio::io::duplex` carrying the channels 8-byte chunk format, in
the task's topology — **producer = connect side** the task's topology: **producer = connect side**
(`ChannelClient::from_connection_with_serving`; its manager (`ChannelClient::from_connection_with_serving`; its manager
allocates the odd channel IDs), **consumer = accept side** (the allocates the odd channel IDs), **consumer = accept side** (the
`ChannelsAdapter` + capturing install hook; the hook captures the `ChannelsAdapter` + capturing install hook; the hook captures the
@@ -126,15 +126,16 @@ Deliverables:
channel survives on either side (fail-closed). channel survives on either side (fail-closed).
- Identities: `consumer_identity()` (`socks5:open` only), - Identities: `consumer_identity()` (`socks5:open` only),
`bind_identity()` (`socks5:bind` only), `both_scopes_identity()` `bind_identity()` (`socks5:bind` only), `both_scopes_identity()`
(both, explicit — ADR-006), `TestIdProvider` + (both, explicit, per ADR-006), `TestIdProvider` +
`TEST_AUTH_TOKEN` (the token-resolver path, unit-tested both `TEST_AUTH_TOKEN` (the token-resolver path, unit-tested both
directions). directions).
- In-process seams (no sockets, no `local`): `echo_dial()` - In-process seams (no sockets, no `local`): `echo_dial()`
(echo-per-dial, exercised through `pump_bidi` — the producer data (echo-per-dial, exercised through `pump_bidi`, the producer data
(echo-per-dial, exercised through `pump_bidi`, the producer data
plane's shape), `refusing_dial(err)` (variant-preserving per-call plane's shape), `refusing_dial(err)` (variant-preserving per-call
reconstruction — the seam errors are not `Clone`), reconstruction, as the seam errors are not `Clone`),
`echo_egress()` (a `ChanEgress` whose `next_reply` parks on `echo_egress()` (a `ChanEgress` whose `next_reply` parks on
`mpsc::UnboundedReceiver::recv()` — `Pending` when idle, the `mpsc::UnboundedReceiver::recv()`: `Pending` when idle, with the
readiness contract verified with a 50ms timeout probe), readiness contract verified with a 50ms timeout probe),
`refusing_egress(err)`, `echo_accept()` (reply#1 advertises a `refusing_egress(err)`, `echo_accept()` (reply#1 advertises a
static in-process BND.ADDR, `ECHO_BIND_PORT` 2121; the accept static in-process BND.ADDR, `ECHO_BIND_PORT` 2121; the accept
@@ -144,7 +145,7 @@ Deliverables:
- `register_socks5_openable_minimal` + `socks5_session_establisher`: - `register_socks5_openable_minimal` + `socks5_session_establisher`:
the producer-side wiring helper over alkcall 0.8.0's the producer-side wiring helper over alkcall 0.8.0's
`ChannelCore::register_openable_with_establisher` (spec from `ChannelCore::register_openable_with_establisher` (spec from
`socks5_open_spec()`, `Establishment::default()` — session gate `socks5_open_spec()`, `Establishment::default()` for a session gate
only; the dial is in-stream). The establisher witnesses the only; the dial is in-stream). The establisher witnesses the
per-call opener identity id (CF-006) into an `IdentityWitness`, per-call opener identity id (CF-006) into an `IdentityWitness`,
asserted end-to-end in the smoke test. asserted end-to-end in the smoke test.
@@ -168,37 +169,37 @@ Deliverables:
`consumer_call.call(OP_SOCKS5_OPEN, Socks5Params::as_json())` and `consumer_call.call(OP_SOCKS5_OPEN, Socks5Params::as_json())` and
adopts on `consumer_manager` (the alktunnels `wire_with` + adopts on `consumer_manager` (the alktunnels `wire_with` +
`open_reverse_channel` shape) rather than `open_reverse_channel` shape) rather than
`ChannelClient::open_channel` — in this topology the open op is `ChannelClient::open_channel`: in this topology the open op is
served by the producer's registry and reached from the accept served by the producer's registry and reached from the accept
side's channel-0 call surface; `open_channel` remains the side's channel-0 call surface; `open_channel` remains the
consumer-session surface for the consumer task's session types. consumer-session surface for the consumer task's session types.
- The harness is `tests/harness.rs` only (dev-only) — verified out - The harness is `tests/harness.rs` only (dev-only), verified out
of the wasm build by the targeted checks below. of the wasm build by the targeted checks below.
## Summary ## Summary
> Filled on completion (2026-09-23). > Filled on completion (2026-09-23).
Built `tests/harness.rs` — the integration test harness: the Built `tests/harness.rs`, the integration test harness: the
producer↔consumer topology over one `tokio::io::duplex` (producer = producer↔consumer topology over one `tokio::io::duplex` (producer =
connect side/serving; consumer = accept side/adapter + capturing connect side/serving; consumer = accept side/adapter + capturing
hook), the alkcall 0.8.0 establishment surface consumed for the hook), the alkcall 0.8.0 establishment surface consumed for the
first time by this crate (`register_openable_with_establisher`, first time by this crate (`register_openable_with_establisher`,
`Establishment::default`, the consumer-side call + adopt dance) — `Establishment::default`, the consumer-side call + adopt dance), and
the first-consumption risk retired with 14 green tests. the first-consumption risk retired with 14 green tests.
- The three identities + `TestIdProvider` (all unit-tested). - The three identities + `TestIdProvider` (all unit-tested).
- All in-process seam closures: `echo_dial`/`refusing_dial` (variant- - All in-process seam closures: `echo_dial`/`refusing_dial` (variant-
preserving), `echo_egress` (readiness contract verified — a 50ms preserving), `echo_egress` (readiness contract verified: a 50ms
timeout probe proves `next_reply` parks, never a hot `Ok(None)`) / timeout probe proves `next_reply` parks, never a hot `Ok(None)`) /
`refusing_egress`, `echo_accept` (immediate resolve + echo) / `refusing_egress`, `echo_accept` (immediate resolve + echo) /
`refusing_accept_factory` / `refusing_accept` (two-stage refusal `refusing_accept_factory` / `refusing_accept` (two-stage refusal
shapes, mapping-table-verified through `to_reply_error()`). shapes, mapping-table-verified through `to_reply_error()`).
- The producer registration helper (`register_socks5_openable_minimal` - The producer registration helper (`register_socks5_openable_minimal`
+ `socks5_session_establisher` + `RegisterMode`) — the single place + `socks5_session_establisher` + `RegisterMode`), the single place
the producer-side wiring lives; the CF-006 identity witness rides the producer-side wiring lives; the CF-006 identity witness rides
it and is asserted. it and is asserted.
- `RawRfcClient` — the raw RFC 1928 client helper over any - `RawRfcClient`: the raw RFC 1928 client helper over any
`AsyncRead + AsyncWrite`, round-tripped against a hand-rolled `AsyncRead + AsyncWrite`, round-tripped against a hand-rolled
byte-level server (success + refusal replies). byte-level server (success + refusal replies).
- The smoke test: harness up → producer registered → consumer open + - The smoke test: harness up → producer registered → consumer open +
@@ -211,5 +212,5 @@ Verification: `cargo test` (127 green: 76 lib + 14 harness + 37
rfc1928-differential), `cargo test --all-features` green, `cargo rfc1928-differential), `cargo test --all-features` green, `cargo
clippy --all-targets -- -D warnings` clean, `cargo fmt --check` clippy --all-targets -- -D warnings` clean, `cargo fmt --check`
clean, `cargo doc --no-deps` zero warnings, both wasm checks clean, `cargo doc --no-deps` zero warnings, both wasm checks
(`cargo check` + `cargo clippy` on `wasm32-unknown-unknown`) clean — (`cargo check` + `cargo clippy` on `wasm32-unknown-unknown`) clean,
the harness stays out of the wasm build. and the harness stays out of the wasm build.
+19 -19
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/core-wire-codec id: implementation/core-wire-codec
name: Core — the ASSOCIATE datagram frame codec (src/wire.rs) name: "Core: the ASSOCIATE datagram frame codec (src/wire.rs)"
status: completed status: completed
depends_on: [implementation/core-crate-deps] depends_on: [implementation/core-crate-deps]
scope: narrow scope: narrow
@@ -15,12 +15,12 @@ Implement `src/wire.rs` per wire.md §The Data Plane and bast.md: the
first-party `[len: u16 BE][addr + data]` frame codec for the UDP first-party `[len: u16 BE][addr + data]` frame codec for the UDP
ASSOCIATE datagram stage (ADR-003), re-implemented in first-party ASSOCIATE datagram stage (ADR-003), re-implemented in first-party
code shaped on alktunnels' length codec (`/workspace/@alkdev/ code shaped on alktunnels' length codec (`/workspace/@alkdev/
alktunnels/src/wire.rs` — `frame_datagram`/`DatagramReader` — same alktunnels/src/wire.rs`, the `frame_datagram`/`DatagramReader` pair, with the same
F-2 behavior, but the payload here is the RFC 1928 address encoding F-2 behavior, but the payload here is the RFC 1928 address encoding
plus data, not a bare payload). plus data, not a bare payload).
The codec is the crate's one framed stage (CONNECT/BIND are raw The codec is the crate's one framed stage (CONNECT/BIND are raw
pass-through — no framing). Framing is applied **exactly once, by pass-through, with no framing). Framing is applied **exactly once, by
this layer**: `Egress::next_reply` returns *inner* bytes this layer**: `Egress::next_reply` returns *inner* bytes
(`addr + data`); the producer's frame writer adds the single length (`addr + data`); the producer's frame writer adds the single length
prefix (the double-framing bug class POC #2 caught is an prefix (the double-framing bug class POC #2 caught is an
@@ -29,35 +29,35 @@ API-design invariant this module's docs must state).
Deliverables: Deliverables:
1. **`frame(addr: &TargetAddr, payload: &[u8]) -> Result<Bytes, 1. **`frame(addr: &TargetAddr, payload: &[u8]) -> Result<Bytes,
WireError>`** — one frame per datagram: `[len: u16 BE][addr + WireError>`**, one frame per datagram: `[len: u16 BE][addr +
data]` where `addr + data` is `TargetAddr::to_be_bytes()` (ATYP + data]` where `addr + data` is `TargetAddr::to_be_bytes()` (ATYP +
addr + port) followed by the payload. addr + port) followed by the payload.
2. **The joint bound (the load-bearing constraint)**: reject 2. **The joint bound (the load-bearing constraint)**: reject
`Oversize` at frame time whenever `len(addr) + len(payload) > `Oversize` at frame time whenever `len(addr) + len(payload) >
65535`. The UDP payload bound alone (65507) does NOT fit u16 65535`. The UDP payload bound alone (65507) does NOT fit u16
unconditionally — a 65507-byte datagram to a 255-character domain unconditionally: a 65507-byte datagram to a 255-character domain
is 65766 frame bytes. Max data: 65276 for domain-form is 65766 frame bytes. Max data: 65276 for domain-form
destinations, 65516 for IPv6, 65528 for IPv4. No unchecked u16 destinations, 65516 for IPv6, 65528 for IPv4. No unchecked u16
arithmetic on the payload bound (wire.md's explicit requirement). arithmetic on the payload bound (wire.md's explicit requirement).
Expose `addr_encoding_len(addr)` (7 v4 / 19 v6 / 259 max domain) Expose `addr_encoding_len(addr)` (7 v4 / 19 v6 / 259 max domain)
so callers can compute the joint bound. so callers can compute the joint bound.
3. **`DatagramReader`** — the incremental decoder: feed it whatever 3. **`DatagramReader`**, the incremental decoder: feed it whatever
`read()` yields; it reassembles frames across chunk boundaries `read()` yields; it reassembles frames across chunk boundaries
(split frames, batched frames, partial headers). Returns (split frames, batched frames, partial headers). Returns
`(TargetAddr, Bytes)` per completed frame — it decodes the addr `(TargetAddr, Bytes)` per completed frame; it decodes the addr
sub-layout (ATYP determines the address length) and splits sub-layout (ATYP determines the address length) and splits
`addr + data`. Infallible decode direction (the oversize bound is `addr + data`. Infallible decode direction (the oversize bound is
enforced at frame time); `is_mid_frame()` diagnostic for teardown. enforced at frame time); `is_mid_frame()` diagnostic for teardown.
4. **The F-2 invariant, correctly stated**: a **zero-length payload** 4. **The F-2 invariant, correctly stated**: a **zero-length payload**
is a legal empty datagram — never EOF. (`len == 0` is unreachable is a legal empty datagram, and never EOF. (`len == 0` is unreachable
on this wire since `addr` is always ≥ 7 bytes; the invariant that on this wire since `addr` is always ≥ 7 bytes; the invariant that
matters is payload-level. See the doc-sync deliverable.) matters is payload-level. See the doc-sync deliverable.)
5. **Doc-sync (wire.md + bast.md residue)**: both docs say "`len = 0` 5. **Doc-sync (wire.md + bast.md residue)**: both docs say "`len = 0`
is a legal empty datagram" while also pinning that `len` covers is a legal empty datagram" while also pinning that `len` covers
`addr + data` (joint bound) — `len = 0` is unreachable since `addr + data` (joint bound); `len = 0` is unreachable since
`addr` ≥ 7 bytes. Correct both docs to state the operative `addr` ≥ 7 bytes. Correct both docs to state the operative
invariant at payload level ("a zero-length payload is a legal invariant at payload level ("a zero-length payload is a legal
empty datagram, never EOF — the F-2 invariant") in the same empty datagram, never EOF, per the F-2 invariant") in the same
change; do not silently diverge. change; do not silently diverge.
6. **EOF layering note in the module docs**: EOF is the 6. **EOF layering note in the module docs**: EOF is the
channels-level sentinel on the `BiStream`, outside this codec; channels-level sentinel on the `BiStream`, outside this codec;
@@ -69,7 +69,7 @@ split across awkward chunks, batched frames in one chunk, partial
header waits, joint-bound rejection (the 65507 + 255-char-domain header waits, joint-bound rejection (the 65507 + 255-char-domain
case), oversize at frame time, mid-frame state observable, garbage case), oversize at frame time, mid-frame state observable, garbage
ATYP decode error (the `udp_undecodable_frame_ends_association` ATYP decode error (the `udp_undecodable_frame_ends_association`
shape — the decode error type must distinguish a bad ATYP so the shape: the decode error type must distinguish a bad ATYP so the
producer stage can fail the association loud). producer stage can fail the association loud).
## Acceptance Criteria ## Acceptance Criteria
@@ -97,7 +97,7 @@ producer stage can fail the association loud).
- docs/architecture/decisions/003-udp-handle-swap.md (framing-once, - docs/architecture/decisions/003-udp-handle-swap.md (framing-once,
F-2) F-2)
- Template: `/workspace/@alkdev/alktunnels/src/wire.rs` (the length - Template: `/workspace/@alkdev/alktunnels/src/wire.rs` (the length
codec + its test matrix — shape on it, do not depend on it) codec + its test matrix: shape on it, do not depend on it)
- POC sketch: `/workspace/alksocks-udp-poc/src/datagram.rs` - POC sketch: `/workspace/alksocks-udp-poc/src/datagram.rs`
(`frame`/`inner`/`unframe`/`addr_encoding_len`) (`frame`/`inner`/`unframe`/`addr_encoding_len`)
@@ -111,14 +111,14 @@ producer stage can fail the association loud).
`Result`s rather than panicking or skipping: reassembly is `Result`s rather than panicking or skipping: reassembly is
infallible (oversize enforced at frame time bounds the buffer by infallible (oversize enforced at frame time bounds the buffer by
the u16 declared length), so the only failure per completed frame the u16 declared length), so the only failure per completed frame
is the addr decode, typed distinctly — `BadAtyp(u8)` (the is the addr decode, typed distinctly: `BadAtyp(u8)` (the
`udp_undecodable_frame_ends_association` shape, POC #2), `udp_undecodable_frame_ends_association` shape, POC #2),
`ShortAddress`, `DomainNotUtf8`, `EmptyFrame` (wire `len == 0` — `ShortAddress`, `DomainNotUtf8`, `EmptyFrame` (wire `len == 0`,
unreachable from a compliant peer; protocol garbage). The reader unreachable from a compliant peer; protocol garbage). The reader
consumes the frame's bytes either way and keeps going; the caller consumes the frame's bytes either way and keeps going; the caller
fails the association loud on the first error. fails the association loud on the first error.
- **The joint bound**: `frame` computes `len(addr) + len(payload)` - **The joint bound**: `frame` computes `len(addr) + len(payload)`
with `checked_add` (saturating past the bound — no unchecked u16 with `checked_add` (saturating past the bound, with no unchecked u16
arithmetic) and rejects `Oversize(total, max)` when it exceeds arithmetic) and rejects `Oversize(total, max)` when it exceeds
`MAX_INNER_LEN`. `addr_encoding_len` exposes 7 (v4) / 19 (v6) / `MAX_INNER_LEN`. `addr_encoding_len` exposes 7 (v4) / 19 (v6) /
`4 + domain.len()` (domain, 259 max) so callers compute the joint `4 + domain.len()` (domain, 259 max) so callers compute the joint
@@ -127,7 +127,7 @@ producer stage can fail the association loud).
per-form maxima at the exact limit (65276 domain / 65516 v6 / per-form maxima at the exact limit (65276 domain / 65516 v6 /
65528 v4) and one-over rejection. 65528 v4) and one-over rejection.
- **Decode direction**: hand-parsed addr sub-layout over the borrowed - **Decode direction**: hand-parsed addr sub-layout over the borrowed
frame (the sync mirror of the forked `read_address` — the stage's frame (the sync mirror of the forked `read_address`, since the stage's
decode must not be async), UTF-8-validated like the fork's codec, decode must not be async), UTF-8-validated like the fork's codec,
payload = the remainder. `TargetAddr::to_be_bytes()`'s output is payload = the remainder. `TargetAddr::to_be_bytes()`'s output is
the encode direction verbatim. the encode direction verbatim.
@@ -136,10 +136,10 @@ producer stage can fail the association loud).
state the F-2 invariant at payload level ("a zero-length payload is state the F-2 invariant at payload level ("a zero-length payload is
a legal empty datagram, never EOF"), with `len == 0` (zero inner a legal empty datagram, never EOF"), with `len == 0` (zero inner
bytes) noted unreachable from a compliant peer since `addr` ≥ 7 bytes) noted unreachable from a compliant peer since `addr` ≥ 7
bytes — the residue corrected in this same change. bytes, and the residue was corrected in this same change.
- A first cut returned bare `(TargetAddr, Bytes)` per frame; the - A first cut returned bare `(TargetAddr, Bytes)` per frame; the
garbage-ATYP deliverable (typed decode error, not a panic) forced garbage-ATYP deliverable (typed decode error, not a panic) forced
the `Result` per frame — the shape the producer stage's loud-fail the `Result` per frame, which is the shape the producer stage's loud-fail
needs. needs.
## Summary ## Summary
@@ -173,4 +173,4 @@ buffering, all four decode-error shapes); lib.rs re-exports
`cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`,
`cargo doc --no-deps`, `cargo check --target `cargo doc --no-deps`, `cargo check --target
wasm32-unknown-unknown`, `cargo clippy --target wasm32-unknown-unknown`, `cargo clippy --target
wasm32-unknown-unknown -- -D warnings` — all clean. wasm32-unknown-unknown -- -D warnings`: all clean.
+33 -33
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/door-tcp id: implementation/door-tcp
name: Front doors — the TCP door (raw-pipe + RFC 1929 cred-bearing) and the BIND door name: "Front doors: the TCP door (raw-pipe + RFC 1929 cred-bearing) and the BIND door"
status: completed status: completed
depends_on: [implementation/local-providers] depends_on: [implementation/local-providers]
scope: moderate scope: moderate
@@ -12,23 +12,23 @@ level: implementation
## Description ## Description
The consumer's TCP front door (feature `local`): the ssh `-D` local The consumer's TCP front door (feature `local`): the ssh `-D` local
exposure — a real `socks5://127.0.0.1:port` endpoint that vanilla exposure, a real `socks5://127.0.0.1:port` endpoint that vanilla
clients (curl, browsers, tun2proxy) point at. Two variants (ADR-005), clients (curl, browsers, tun2proxy) point at. Two variants (ADR-005),
plus the BIND door's reply#2 fidelity. The door is the plus the BIND door's reply#2 fidelity. The door is the
vanilla↔ACL boundary; the RFC codec lives only here. vanilla↔ACL boundary; the RFC codec lives only here.
Deliverables: Deliverables:
1. **`src/door.rs`** (feature `local`) — the door surface per 1. **`src/door.rs`** (feature `local`), the door surface per
consumer.md §The Front Doors + overview.md §Module Map: consumer.md §The Front Doors + overview.md §Module Map:
- **The NoAuth door** (POC #1's raw-pipe shape): bind a real - **The NoAuth door** (POC #1's raw-pipe shape): bind a real
`TcpListener`; per accepted connection, pipe it raw onto a `TcpListener`; per accepted connection, pipe it raw onto a
channel `BiStream` (`copy_bidirectional`, **zero protocol channel `BiStream` (`copy_bidirectional`, **zero protocol
translation**). The producer runs the whole RFC conversation translation**). The producer runs the whole RFC conversation
through the pipe — the door adds nothing. This is the through the pipe; the door adds nothing. This is the
loopback-only / wrapper-aware shape. loopback-only / wrapper-aware shape.
- **The cred-bearing door** (RFC 1929): the door terminates the - **The cred-bearing door** (RFC 1929): the door terminates the
local RFC conversation itself — `accept_password_auth`-shaped local RFC conversation itself: `accept_password_auth`-shaped
(the forked surface: `Socks5ServerProtocol::start` → (the forked surface: `Socks5ServerProtocol::start` →
`negotiate_auth(&[PasswordAuthentication])` → `negotiate_auth(&[PasswordAuthentication])` →
`read_username_password` → check → `accept`/`reject`), then `read_username_password` → check → `accept`/`reject`), then
@@ -46,18 +46,18 @@ Deliverables:
- **The identity mechanism**: the credential→identity mapping - **The identity mechanism**: the credential→identity mapping
yields an alkcall `Identity` (scopes included); the door opens yields an alkcall `Identity` (scopes included); the door opens
the producer channel with it. The landed consumer sessions take the producer channel with it. The landed consumer sessions take
`&ChannelClient` — the door's per-credential identity rides the `&ChannelClient`, so the door's per-credential identity rides the
open-op call's `auth_token` payload (CF-005 precedence (1): open-op call's `auth_token` payload (CF-005 precedence (1):
payload `auth_token` → `identity_provider` resolution) or a payload `auth_token` → `identity_provider` resolution) or a
per-credential `ChannelClient` — decide at implementation per-credential `ChannelClient`; decide at implementation
against the landed alkcall 0.8.0 surface against the landed alkcall 0.8.0 surface
(`CallConnection::call_with_payload` carries `auth_token`; (`CallConnection::call_with_payload` carries `auth_token`;
`ServingConfig.identity_provider` resolves it producer-side) `ServingConfig.identity_provider` resolves it producer-side)
and record the choice in Notes. The door checks credentials and record the choice in Notes. The door checks credentials
itself (no forked `AuthMethod` impl needed — ADR-005). itself (no forked `AuthMethod` impl needed, per ADR-005).
- **The BIND door**: same listener shape; a vanilla BIND - **The BIND door**: same listener shape; a vanilla BIND
conversation rides `BindSession` (reply#1's advertised address conversation rides `BindSession` (reply#1's advertised address
passes through to the vanilla client unchanged — the passes through to the vanilla client unchanged, because the
conversation is the client's own; the door adds nothing, conversation is the client's own; the door adds nothing,
consumer.md §The BIND door's reply#2 fidelity). The BIND door consumer.md §The BIND door's reply#2 fidelity). The BIND door
rides the same door variant split (NoAuth raw-pipe vs rides the same door variant split (NoAuth raw-pipe vs
@@ -65,7 +65,7 @@ Deliverables:
- **The accept path owns the idle defense** (ADR-014): an - **The accept path owns the idle defense** (ADR-014): an
accepted connection that sends no first byte within a accepted connection that sends no first byte within a
configured window is closed door-side (an accepted socket is configured window is closed door-side (an accepted socket is
not a channel — the cap regime does not bound it); plus not a channel, so the cap regime does not bound it); plus
per-credential/per-peer session caps and accept-queue bounds as per-credential/per-peer session caps and accept-queue bounds as
constructor config. Keep the v1 shape simple: an idle-first-byte constructor config. Keep the v1 shape simple: an idle-first-byte
timeout + a max-concurrent-sessions cap are the two knobs. timeout + a max-concurrent-sessions cap are the two knobs.
@@ -74,7 +74,7 @@ Deliverables:
.credentials(cred_table).bind("127.0.0.1:1080").build()` → .credentials(cred_table).bind("127.0.0.1:1080").build()` →
`serve(channel_client)` (the NoAuth door omits `.credentials`). `serve(channel_client)` (the NoAuth door omits `.credentials`).
`serve` runs the accept loop; per-connection failures are `serve` runs the accept loop; per-connection failures are
logged + isolated (the loop continues — the alktunnels logged + isolated (the loop continues, matching the alktunnels
per-accept isolation posture). per-accept isolation posture).
2. **Tests** (`tests/door-tcp.rs`): a vanilla RFC 1928 client (the 2. **Tests** (`tests/door-tcp.rs`): a vanilla RFC 1928 client (the
differential-suite raw-client pattern) through the NoAuth door → differential-suite raw-client pattern) through the NoAuth door →
@@ -94,18 +94,18 @@ Deliverables:
--check` clean --check` clean
- [x] Default wasm checks stay green (door code is `local`-gated) - [x] Default wasm checks stay green (door code is `local`-gated)
- [x] The NoAuth door is a literal raw pipe (zero protocol - [x] The NoAuth door is a literal raw pipe (zero protocol
translation) — pinned by test translation), pinned by test
- [x] The cred-bearing door terminates RFC 1929 locally and - [x] The cred-bearing door terminates RFC 1929 locally and
re-originates in-band (never pipes raw post-auth) — pinned by re-originates in-band (never pipes raw post-auth), pinned by
test (a vanilla client's command bytes must not reach the test (a vanilla client's command bytes must not reach the
producer as greeting bytes) producer as greeting bytes)
- [x] The credential→identity mapping is the static config table - [x] The credential→identity mapping is the static config table
(ADR-005); the password never crosses the channel as a SOCKS (ADR-005); the password never crosses the channel as a SOCKS
password — pinned by test (the producer's establisher witness password; pinned by test in that the producer's establisher witness
sees the mapped identity, never the password) sees the mapped identity, never the password)
- [x] The idle-first-byte timeout + session cap land as constructor - [x] The idle-first-byte timeout + session cap land as constructor
config — pinned by tests config, pinned by tests
- [x] Per-connection failure isolation — pinned by test - [x] Per-connection failure isolation, pinned by test
## References ## References
@@ -118,26 +118,26 @@ Deliverables:
connect_poc.rs` (`front_door_raw_pipe_vanilla_client`), `/workspace/ connect_poc.rs` (`front_door_raw_pipe_vanilla_client`), `/workspace/
alksocks-bind-poc/tests/bind_poc.rs` (`bind_vanilla_front_door`) alksocks-bind-poc/tests/bind_poc.rs` (`bind_vanilla_front_door`)
- Landed surfaces: `src/consumer.rs` (the session types), - Landed surfaces: `src/consumer.rs` (the session types),
`src/rfc1928/server.rs` (`accept_password_auth` — the forked `src/rfc1928/server.rs` (`accept_password_auth`, the forked
cred-bearing shape), alkcall CF-005 (`auth_token` precedence) cred-bearing shape), alkcall CF-005 (`auth_token` precedence)
## Notes ## Notes
> Filled by the implementation agent (2026-09-25). > Filled by the implementation agent (2026-09-25).
- **The identity mechanism — decided (the task's open choice)**: the - **The identity mechanism, decided (the task's open choice)**: the
per-credential identity rides the open-op call's `auth_token` per-credential identity rides the open-op call's `auth_token`
payload (CF-005 precedence (1)), never a per-credential payload (CF-005 precedence (1)), never a per-credential
`ChannelClient`. Mechanically: the door wraps the client's `ChannelClient`. Mechanically: the door wraps the client's
channel-0 `CallConnection` once (`TokenClient::wrap` — the raw channel-0 `CallConnection` once (`TokenClient::wrap`, the raw
`call_with_payload` surface, since the token is a payload-level `call_with_payload` surface, since the token is a payload-level
field, ADR-017 §7) and each credential's open rides the payload field, ADR-017 §7) and each credential's open rides the payload
`auth_token`; the producer's `ServingConfig.identity_provider` `auth_token`; the producer's `ServingConfig.identity_provider`
resolves it and the ACL gate reads the resolved scopes exactly as resolves it and the ACL gate reads the resolved scopes exactly as
for any per-call opener identity. The token is the mapped for any per-call opener identity. The token is the mapped
identity's *id* (the resolution seam's vocabulary — the password identity's *id* (the resolution seam's vocabulary); the password
never rides it). Rationale: one transport, one call connection, never rides it). Rationale: one transport, one call connection,
many identities — a per-credential `ChannelClient` would need a many identities, since a per-credential `ChannelClient` would need a
full transport connection per credential, which the ssh `-D` full transport connection per credential, which the ssh `-D`
single-socket posture cannot afford. single-socket posture cannot afford.
- **New consumer surface (`src/consumer.rs`)**: `TokenClient` (the - **New consumer surface (`src/consumer.rs`)**: `TokenClient` (the
@@ -150,22 +150,22 @@ Deliverables:
`open_and_negotiate`: open-with-token → adopt → negotiate → `open_and_negotiate`: open-with-token → adopt → negotiate →
teardown-at-birth on failure). `Socks5ChannelSession` gained the teardown-at-birth on failure). `Socks5ChannelSession` gained the
`reply_addr` field + accessor (the CONNECT reply's BND.ADDR, `reply_addr` field + accessor (the CONNECT reply's BND.ADDR,
captured at `connect` — the door's vanilla-reply re-encoding rides captured at `connect`; the door's vanilla-reply re-encoding rides
it). The existing constructor surfaces are untouched. it). The existing constructor surfaces are untouched.
- **The door (`src/door.rs`, feature `local`)**: - **The door (`src/door.rs`, feature `local`)**:
- `FrontDoor::builder().credentials(...).bind(...).build()` → - `FrontDoor::builder().credentials(...).bind(...).build()` →
`serve(client)`; `serve_on(listener, client)` is the pre-bound `serve(client)`; `serve_on(listener, client)` is the pre-bound
seam (tests/assembly wire their own listener — the bind decision seam (tests/assembly wire their own listener; the bind decision
stays the caller's). stays the caller's).
- **The NoAuth door**: accept → `open_channel` → - **The NoAuth door**: accept → `open_channel` →
`copy_bidirectional(accepted_socket, BiStream)` — the literal `copy_bidirectional(accepted_socket, BiStream)`, the literal
raw pipe; zero translation (POC #1's shape). raw pipe; zero translation (POC #1's shape).
- **The cred-bearing door**: the forked - **The cred-bearing door**: the forked
`accept_password_auth` shape locally (`negotiate_auth(&[ `accept_password_auth` shape locally (`negotiate_auth(&[
PasswordAuthentication])` → `read_username_password` → check → PasswordAuthentication])` → `read_username_password` → check →
`accept`/`reject`), the checker closing over the static `accept`/`reject`), the checker closing over the static
`CredentialTable` (`user:pass → Identity`, the ADR-005 static `CredentialTable` (`user:pass → Identity`, the ADR-005 static
mapping — the check and the mapping are one lookup), then mapping; the check and the mapping are one lookup), then
`read_command` for the re-origination's parsed target, then the `read_command` for the re-origination's parsed target, then the
in-band sessions with the mapped identity's id as the token. in-band sessions with the mapped identity's id as the token.
Replies are re-encoded to the vanilla client (the RFC codec at Replies are re-encoded to the vanilla client (the RFC codec at
@@ -173,29 +173,29 @@ Deliverables:
`reply_addr()`, the BIND door's reply#1 from `reply1() `reply_addr()`, the BIND door's reply#1 from `reply1()
.bind_addr`, reply#2 from `await_inbound()`'s plane peer; the .bind_addr`, reply#2 from `await_inbound()`'s plane peer; the
post-reply data planes ride `copy_bidirectional` against the post-reply data planes ride `copy_bidirectional` against the
taken in-band streams. **Never pipes raw post-auth** — the taken in-band streams. **Never pipes raw post-auth**, since the
vanilla client's greeting/command bytes were consumed by the vanilla client's greeting/command bytes were consumed by the
door's RFC half; the in-band path re-speaks them from the parsed door's RFC half; the in-band path re-speaks them from the parsed
target (pinned by test: the cred-bearing CONNECT round-trip target (pinned by test: the cred-bearing CONNECT round-trip
succeeding at all is the pin — raw piping would misparse the succeeding at all is the pin, since raw piping would misparse the
command bytes as a greeting). command bytes as a greeting).
- **The BIND door**: same listener shape, riding - **The BIND door**: same listener shape, riding
`BindSession::bind_with_token`; the quiet wait rides the `BindSession::bind_with_token`; the quiet wait rides the
session's `await_inbound`; reply#1's advertised address passes session's `await_inbound`; reply#1's advertised address passes
through unchanged (the door adds nothing). through unchanged (the door adds nothing).
- UDP ASSOCIATE through the TCP door: refused RFC-faithfully at - UDP ASSOCIATE through the TCP door: refused RFC-faithfully at
the door (reply code 0x07 — the UDP door's task, not this one). the door (reply code 0x07; the UDP door's task, not this one).
- **The accept-path defense (ADR-014)**: the idle-first-byte - **The accept-path defense (ADR-014)**: the idle-first-byte
window (a `peek`-based readability wait before any protocol window (a `peek`-based readability wait before any protocol
work — an accepted connection silent past the window is closed work; an accepted connection silent past the window is closed
door-side) and the max-sessions cap (a `Semaphore`; an accept door-side) and the max-sessions cap (a `Semaphore`; an accept
beyond the bound is refused immediately, no channel opened) as beyond the bound is refused immediately, no channel opened) as
constructor config. Defaults: 30s / 256. constructor config. Defaults: 30s / 256.
- **Failure isolation**: per-accept `tokio::spawn`; failures log + - **Failure isolation**: per-accept `tokio::spawn`; failures log +
isolate — the accept loop continues (the alktunnels posture). isolate; the accept loop continues (the alktunnels posture).
- **Harness**: `wire_consumer_side_with_provider` added (the - **Harness**: `wire_consumer_side_with_provider` added (the
consumer-side topology with the producer's consumer-side topology with the producer's
`ServingConfig.identity_provider` configurable — the CF-005 `ServingConfig.identity_provider` configurable (the CF-005
precedence (1) seam the token sessions ride); precedence (1) seam the token sessions ride);
`wire_consumer_side_with` delegates unchanged. `wire_consumer_side_with` delegates unchanged.
- **Test-provider note**: the test `DoorTokenProvider` resolves - **Test-provider note**: the test `DoorTokenProvider` resolves
@@ -208,14 +208,14 @@ Deliverables:
> Filled on completion (2026-09-25). > Filled on completion (2026-09-25).
Implemented the consumer's TCP front door (feature `local`): the Implemented the consumer's TCP front door (feature `local`): the
NoAuth door (the literal raw pipe — accept → channel `BiStream` via NoAuth door (the literal raw pipe: accept → channel `BiStream` via
`copy_bidirectional`, zero protocol translation), the cred-bearing `copy_bidirectional`, zero protocol translation), the cred-bearing
door (RFC 1929 terminates locally through the forked door (RFC 1929 terminates locally through the forked
`accept_password_auth` shape; the static credential→identity table `accept_password_auth` shape; the static credential→identity table
maps the accepted credential; the SOCKS conversation re-originates maps the accepted credential; the SOCKS conversation re-originates
in-band via the token-carrying consumer sessions with the vanilla in-band via the token-carrying consumer sessions with the vanilla
replies re-encoded at the door; the password never crosses the replies re-encoded at the door; the password never crosses the
channel as a SOCKS password), and the BIND door (reply#1 fidelity — channel as a SOCKS password), and the BIND door (reply#1 fidelity:
the advertised address passes through unchanged; the quiet wait + the advertised address passes through unchanged; the quiet wait +
reply#2 + the pass-through data plane). The identity mechanism reply#2 + the pass-through data plane). The identity mechanism
decided + recorded: the per-credential identity rides the open-op decided + recorded: the per-credential identity rides the open-op
+28 -28
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/door-udp id: implementation/door-udp
name: Front doors — the UDP door (the RFC codec boundary) name: "Front doors: the UDP door (the RFC codec boundary)"
status: completed status: completed
depends_on: [implementation/door-tcp] depends_on: [implementation/door-tcp]
scope: narrow scope: narrow
@@ -12,22 +12,22 @@ level: implementation
## Description ## Description
The consumer's UDP front door (feature `local`): a real UDP socket The consumer's UDP front door (feature `local`): a real UDP socket
exposing an ASSOCIATE association to vanilla clients — the POC #2 exposing an ASSOCIATE association to vanilla clients, the POC #2
test-8 shape. The RFC UDP-header codec lives ONLY at this boundary test-8 shape. The RFC UDP-header codec lives ONLY at this boundary
(ADR-003); the in-band wire carries `{addr, data}` frames. (ADR-003); the in-band wire carries `{addr, data}` frames.
Deliverables: Deliverables:
1. **`src/door.rs` additions** (feature `local`) — the UDP door: 1. **`src/door.rs` additions** (feature `local`), the UDP door:
- Bind a real UDP socket (the relay socket — the only place a - Bind a real UDP socket (the relay socket, the only place a
real address exists; the vanilla client `sendto()`s it). real address exists; the vanilla client `sendto()`s it).
- Open the association in-band via `AssociateSession::associate` - Open the association in-band via `AssociateSession::associate`
(the reply's sentinel BND.ADDR is received and never used — (the reply's sentinel BND.ADDR is received and never used, as
the handle is the channel, ADR-003). the handle is the channel, ADR-003).
- **The datagram pump** (two pumps, joined — the ADR-050 - **The datagram pump** (two pumps, joined, per the ADR-050
two-pump contract): socket→channel decodes the RFC header two-pump contract): socket→channel decodes the RFC header
(`parse_udp_request`), **FRAG ≠ 0 → refuse** (drop the (`parse_udp_request`), and **FRAG ≠ 0 → refuse** (drops the
datagram; the v1 posture — fragmentation cannot be expressed datagram; the v1 posture, since fragmentation cannot be expressed
in-band), sends `{addr, data}` in-band in-band), sends `{addr, data}` in-band
(`send_datagram`); channel→socket encodes the reply header (`send_datagram`); channel→socket encodes the reply header
(`new_udp_header`) and sends the RFC-header'd datagram back to (`new_udp_header`) and sends the RFC-header'd datagram back to
@@ -36,21 +36,21 @@ Deliverables:
door's association ends when the channel ends; the door's UDP door's association ends when the channel ends; the door's UDP
socket closes with it. A per-client idle bound (no datagrams socket closes with it. A per-client idle bound (no datagrams
within a window → close the association) is the door-side within a window → close the association) is the door-side
defense (ADR-014) — constructor config, mirroring the TCP defense (ADR-014), constructor config, mirroring the TCP
door's knobs. door's knobs.
- The door's bind address is constructor config (consumer.md: - The door's bind address is constructor config (consumer.md:
"the UDP door's bind address is assembly-layer configuration"). "the UDP door's bind address is assembly-layer configuration").
- Surface shape: follow the TCP door's builder (`FrontDoor` - Surface shape: follow the TCP door's builder (`FrontDoor`
additions or a sibling `UdpFrontDoor` — decide at additions or a sibling `UdpFrontDoor`; decide at
implementation against the landed `door.rs` shape and record implementation against the landed `door.rs` shape and record
in Notes; the spec leaves the exact constructor shape open). in Notes; the spec leaves the exact constructor shape open).
2. **Tests** (`tests/door-udp.rs`): the POC #2 test-8 shape end to 2. **Tests** (`tests/door-udp.rs`): the POC #2 test-8 shape end to
end — a real UDP echo target, the producer with `local::udp_ end: a real UDP echo target, the producer with `local::udp_
egress()`, the door, a vanilla RFC-header'd datagram in → the egress()`, the door, a vanilla RFC-header'd datagram in → the
echo back RFC-header'd (the vanilla client simulated at the RFC echo back RFC-header'd (the vanilla client simulated at the RFC
boundary); FRAG ≠ 0 refused (dropped, association stays healthy); boundary); FRAG ≠ 0 refused (dropped, association stays healthy);
the empty-datagram invariant through the door (a zero-length the empty-datagram invariant through the door (a zero-length
payload round-trips, never EOF — F-2); the idle bound closes an payload round-trips, never EOF, F-2); the idle bound closes an
idle association; a fresh association succeeds after teardown. idle association; a fresh association succeeds after teardown.
## Acceptance Criteria ## Acceptance Criteria
@@ -60,14 +60,14 @@ Deliverables:
--check` clean --check` clean
- [ ] Default wasm checks stay green (door code is `local`-gated) - [ ] Default wasm checks stay green (door code is `local`-gated)
- [ ] The RFC UDP-header codec appears only at the door boundary - [ ] The RFC UDP-header codec appears only at the door boundary
(in-band stays `{addr, data}` frames) — pinned by test (in-band stays `{addr, data}` frames), pinned by test
- [ ] FRAG ≠ 0 refused at the door — pinned by test - [ ] FRAG ≠ 0 refused at the door, pinned by test
- [ ] The empty datagram (zero-length payload) round-trips through - [ ] The empty datagram (zero-length payload) round-trips through
the door, never EOF — pinned by test (the F-2 invariant at the the door, never EOF, pinned by test (the F-2 invariant at the
vanilla boundary) vanilla boundary)
- [ ] Association lifetime = channel lifetime; teardown leaves the - [ ] Association lifetime = channel lifetime; teardown leaves the
connection healthy — pinned by test connection healthy, pinned by test
- [ ] The idle bound is constructor config — pinned by test - [ ] The idle bound is constructor config, pinned by test
## References ## References
@@ -76,8 +76,8 @@ Deliverables:
- docs/architecture/consumer.md §The Front Doors (the UDP door), - docs/architecture/consumer.md §The Front Doors (the UDP door),
§Wiring §Wiring
- POC reference: `/workspace/alksocks-udp-poc/tests/udp_poc.rs` - POC reference: `/workspace/alksocks-udp-poc/tests/udp_poc.rs`
(`udp_front_door_vanilla_client_round_trip` — the pump shape) (`udp_front_door_vanilla_client_round_trip`, the pump shape)
- Landed surfaces: `src/consumer.rs` (`AssociateSession` — - Landed surfaces: `src/consumer.rs` (`AssociateSession`,
`send_datagram`/`recv_datagram`/`split`), `src/rfc1928/mod.rs` `send_datagram`/`recv_datagram`/`split`), `src/rfc1928/mod.rs`
(`new_udp_header`/`parse_udp_request`), `src/wire.rs` (the frame (`new_udp_header`/`parse_udp_request`), `src/wire.rs` (the frame
codec) codec)
@@ -86,34 +86,34 @@ Deliverables:
- **Surface shape** (the spec left the constructor open): a **sibling** - **Surface shape** (the spec left the constructor open): a **sibling**
`UdpFrontDoor` with its own builder (`bind` + `idle`), not `UdpFrontDoor` with its own builder (`bind` + `idle`), not
`FrontDoor` additions — the UDP door shares no accept loop with the `FrontDoor` additions: the UDP door shares no accept loop with the
TCP door (UDP has no connections; one relay socket = one TCP door (UDP has no connections; one relay socket = one
association), and the shared shape would have forced a mode split on association), and the shared shape would have forced a mode split on
every knob. `serve(channel_client)` + `serve_on(socket, client)` every knob. `serve(channel_client)` + `serve_on(socket, client)`
(the test/assembly seam), mirroring the TCP door. (the test/assembly seam), mirroring the TCP door.
- **The pump shape** (an implementation finding worth recording): the - **The pump shape** (an implementation finding worth recording): the
POC #2 shape (both pumps sharing one `AssociateSession` behind a POC #2 shape (both pumps sharing one `AssociateSession` behind a
mutex) deadlocks in this landed surface — the channel read parks mutex) deadlocks in this landed surface: the channel read parks
holding the lock while the socket side's send needs it. The door holding the lock while the socket side's send needs it. The door
instead **splits the association's stream** (`AssociateSession::split` instead **splits the association's stream** (`AssociateSession::split`,
— the door bridge seam): one pump owns the in-band write half the door bridge seam): one pump owns the in-band write half
(socket→channel, framing via the wire codec), the other the read (socket→channel, framing via the wire codec), the other the read
half (channel→socket, the incremental `DatagramReader` decode). The half (channel→socket, the incremental `DatagramReader` decode). The
two pumps join via `select!` (whichever ends first closes the door); two pumps join via `select!` (whichever ends first closes the door);
the pump is inlined in `serve_on` because the split halves borrow the pump is inlined in `serve_on` because the split halves borrow
the session (the borrow must end before the teardown's the session (the borrow must end before the teardown's
`shutdown`). The read half's decode carries a fresh per-call `shutdown`). The read half's decode carries a fresh per-call
`DatagramReader` — the door serves one association per socket and `DatagramReader`, because the door serves one association per socket and
the relay loop drains one frame per call; the mid-frame persistence the relay loop drains one frame per call; the mid-frame persistence
case is the session's own `recv_datagram` contract (tested in case is the session's own `recv_datagram` contract (tested in
consumer-associate). consumer-associate).
- **The client's source address** is learned from the first inbound - **The client's source address** is learned from the first inbound
datagram (std mutex, held never across an await) and reused for datagram (std mutex, held never across an await) and reused for
every reply — the relay's single-client shape. A reply before the every reply, since the relay's shape is single-client. A reply before the
first inbound datagram is parked (none exists in practice). first inbound datagram is parked (none exists in practice).
- **FRAG ≠ 0** drops the datagram and keeps the association (the v1 - **FRAG ≠ 0** drops the datagram and keeps the association (the v1
posture); a malformed RFC header also drops (garbage never goes posture); a malformed RFC header also drops (garbage never goes
in-band — the in-band refusal shape is the producer's in-band; the in-band refusal shape is the producer's
`UndecodableFrame` exit, which the door never triggers by `UndecodableFrame` exit, which the door never triggers by
construction). construction).
- **The idle bound** covers the socket→channel pump's - **The idle bound** covers the socket→channel pump's
@@ -124,7 +124,7 @@ Deliverables:
## Summary ## Summary
Landed the UDP front door (`src/door.rs`, feature `local`): Landed the UDP front door (`src/door.rs`, feature `local`):
`UdpFrontDoor` — a real relay UDP socket (constructor-config bind `UdpFrontDoor`, a real relay UDP socket (constructor-config bind
address) bridged onto one in-band association address) bridged onto one in-band association
(`AssociateSession::associate`; the sentinel BND.ADDR received, never (`AssociateSession::associate`; the sentinel BND.ADDR received, never
used). The RFC UDP-header codec appears only at the door boundary used). The RFC UDP-header codec appears only at the door boundary
@@ -135,7 +135,7 @@ round-trips (F-2); association lifetime = channel lifetime (the door's
socket closes with the pump; teardown reaps the channel); the client socket closes with the pump; teardown reaps the channel); the client
idle bound is constructor config and closes an idle association idle bound is constructor config and closes an idle association
door-side. Two-pump shape with the split-halves correction recorded in door-side. Two-pump shape with the split-halves correction recorded in
Notes. Tests: `tests/door-udp.rs` (4 tests — the round-trip + empty Notes. Tests: `tests/door-udp.rs` (4 tests: the round-trip + empty
datagram, FRAG refusal + health, idle bound + fresh-association datagram, FRAG refusal + health, idle bound + fresh-association
health, fresh-association-after-teardown). Verification: `cargo test` health, fresh-association-after-teardown). Verification: `cargo test`
green, `cargo test --all-features` green, clippy `-D warnings` clean green, `cargo test --all-features` green, clippy `-D warnings` clean
+29 -29
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/fix-bind-reply2-reentry id: implementation/fix-bind-reply2-reentry
name: "Fix — BIND reply#2 re-entry guard + consumer session hygiene (review-002 C-1, D-1, N-6, S-2)" name: "Fix: BIND reply#2 re-entry guard + consumer session hygiene (review-002 C-1, D-1, N-6, S-2)"
status: completed status: completed
depends_on: [] depends_on: []
scope: narrow scope: narrow
@@ -15,11 +15,11 @@ Close review 002's one reachable silent-corruption path plus the
consumer-side residues that ride the same file consumer-side residues that ride the same file
(`docs/reviews/002-protocol-core-implementation-review.md`): (`docs/reviews/002-protocol-core-implementation-review.md`):
1. **C-1 (major) — the reply#2 phase is re-entrant with no 1. **C-1 (major): the reply#2 phase is re-entrant with no
spent-marker.** `BindSession::await_inbound` spent-marker.** `BindSession::await_inbound`
(`src/consumer.rs:757-763`) takes `&mut self`, leaves `inner` (`src/consumer.rs:757-763`) takes `&mut self`, leaves `inner`
populated, and returns a `BindDataPlane<'_>` borrowing the same populated, and returns a `BindDataPlane<'_>` borrowing the same
stream — nothing marks the phase consumed. A second call runs stream, and nothing marks the phase consumed. A second call runs
`read_reply2` against the *live data plane* (post-reply#2 bytes `read_reply2` against the *live data plane* (post-reply#2 bytes
are the accepted peer's raw application data): a baffling are the accepted peer's raw application data): a baffling
`UnsupportedSocksVersion` at best, silent 10+-byte data `UnsupportedSocksVersion` at best, silent 10+-byte data
@@ -29,7 +29,7 @@ consumer-side residues that ride the same file
spent-state-guarded; this is the gap. spent-state-guarded; this is the gap.
Fix per the review's sketch: make the reply#2 phase consumed in Fix per the review's sketch: make the reply#2 phase consumed in
the session's established posture — `inner.take()` (a second call the session's established posture: `inner.take()` (a second call
hits the `None` arm and returns the existing `Ok(None)` spent hits the `None` arm and returns the existing `Ok(None)` spent
shape, which the method's doc *already* claims: "`None` after a shape, which the method's doc *already* claims: "`None` after a
taken plane") or an equivalent spent-marker riding the session. taken plane") or an equivalent spent-marker riding the session.
@@ -38,33 +38,33 @@ consumer-side residues that ride the same file
`Socks5ChannelSession::into_inner` already pins) while the `Socks5ChannelSession::into_inner` already pins) while the
session stays the teardown owner for the channel entry. Either session stays the teardown owner for the channel entry. Either
mechanism is acceptable; the spent shape must match the session mechanism is acceptable; the spent shape must match the session
family's posture (`Ok(None)` or the typed `SpentSessionError` — family's posture (`Ok(None)` or the typed `SpentSessionError`; pick
pick one, document it). one and document it).
2. **D-1 (minor) — `BindReply1::await_inbound` is dead surface** 2. **D-1 (minor): `BindReply1::await_inbound` is dead surface**
(0% covered; every test drives the session's method). The choice (0% covered; every test drives the session's method). The choice
interacts with C-1 — pick one normative reply#2 reader: interacts with C-1, so pick one normative reply#2 reader:
- (a) the session's `await_inbound` (the review's lean: document - (a) the session's `await_inbound` (the review's lean: document
`reply1()` as a view, demote `BindReply1::await_inbound` to a `reply1()` as a view, demote `BindReply1::await_inbound` to a
private helper or cut it), or private helper or cut it), or
- (b) `BindReply1::await_inbound` (the task spec's intent — - (b) `BindReply1::await_inbound` (the task spec's intent,
`tasks/implementation/core-consumer-bind.md:74-75`; the `tasks/implementation/core-consumer-bind.md:74-75`; the
session's method delegates to it and the handle is tested session's method delegates to it and the handle is tested
through). through).
The alternate path must be impossible to double-read (private, The alternate path must be impossible to double-read (private,
cut, or spent-guarded). cut, or spent-guarded).
3. **N-6 (observation) — the three session constructors share a 3. **N-6 (observation): the three session constructors share a
near-identical prologue** (open → `BiStream::from_joined` → near-identical prologue** (open → `BiStream::from_joined` →
negotiated → teardown-on-err; `src/consumer.rs:121-149`, negotiated → teardown-on-err; `src/consumer.rs:121-149`,
`290-324`, `702-733`). Collapse into one private `290-324`, `702-733`). Collapse into one private
`open_and_negotiate` helper — the teardown-at-birth invariant in `open_and_negotiate` helper, holding the teardown-at-birth invariant in
one place instead of three (a batch-2 constructor then cannot one place instead of three (a batch-2 constructor then cannot
hand-copy the reap path). Zero behavior change. hand-copy the reap path). Zero behavior change.
4. **S-2 (observation) — one doc line on the three sessions' `reap`** 4. **S-2 (observation): one doc line on the three sessions' `reap`**
(`src/consumer.rs:197-202`, `490-495`, `787-792`): the dropped (`src/consumer.rs:197-202`, `490-495`, `787-792`): the dropped
`teardown_channel` `JoinHandle` is deliberate — the producer-side `teardown_channel` `JoinHandle` is deliberate: the producer-side
pump task finishes via EOF propagation (the graceful shape); pump task finishes via EOF propagation (the graceful shape);
`clear_all` aborts instead (the substrate's abrupt path). No code `clear_all` aborts instead (the substrate's abrupt path). No code
change; prevents a future reader "fixing" the handle into an change; prevents a future reader "fixing" the handle into an
@@ -83,7 +83,7 @@ accept-failure mappings unaffected).
data-plane bytes asserted untouched data-plane bytes asserted untouched
- [ ] The `reply1()`-twice re-entry path is closed (the D-1 - [ ] The `reply1()`-twice re-entry path is closed (the D-1
decision: one normative reader; the alternate private/cut/ decision: one normative reader; the alternate private/cut/
spent-guarded) — the choice recorded in the task Notes spent-guarded), with the choice recorded in the task Notes
- [ ] The session types' docs match the landed spent posture (the - [ ] The session types' docs match the landed spent posture (the
"`None` after a taken plane" claim is true of the code) "`None` after a taken plane" claim is true of the code)
- [ ] The three constructors share one `open_and_negotiate` helper; - [ ] The three constructors share one `open_and_negotiate` helper;
@@ -100,7 +100,7 @@ accept-failure mappings unaffected).
- tasks/implementation/core-consumer-bind.md (the landed BIND - tasks/implementation/core-consumer-bind.md (the landed BIND
session; the `BindReply1` handle's spec'd intent) session; the `BindReply1` handle's spec'd intent)
- tasks/implementation/review-protocol-core.md (R-2's - tasks/implementation/review-protocol-core.md (R-2's
`SpentSessionError` shape — the consistency target) `SpentSessionError` shape, the consistency target)
## Notes ## Notes
@@ -109,34 +109,34 @@ accept-failure mappings unaffected).
- **C-1 landed as the take-shape** (the review's first sketch): `BindSession - **C-1 landed as the take-shape** (the review's first sketch): `BindSession
::await_inbound` takes the session's inner stream before reading reply#2 ::await_inbound` takes the session's inner stream before reading reply#2
(`inner.take()`); the returned `BindDataPlane` **owns** the (`inner.take()`); the returned `BindDataPlane` **owns** the
`Socks5Stream<BiStream>` while the session stays the teardown owner — the `Socks5Stream<BiStream>` while the session stays the teardown owner; that is the
`TakenInner` pairing posture `Socks5ChannelSession::into_inner` pinned, now `TakenInner` pairing posture `Socks5ChannelSession::into_inner` pinned, now
carried by `BindDataPlane` itself (no new pairing type — the plane is the carried by `BindDataPlane` itself (no new pairing type: the plane is the
pair's inner half; the session is the pair's teardown owner). A second pair's inner half; the session is the pair's teardown owner). A second
call hits the `None` arm → the family's spent shape `Ok(None)` (the call hits the `None` arm → the family's spent shape `Ok(None)` (the
method's doc claim "`None` after a taken plane" is now true). The spent method's doc claim "`None` after a taken plane" is now true). The spent
shape chosen is **`Ok(None)`** (not a typed `SpentSessionError`): the shape chosen is **`Ok(None)`** (not a typed `SpentSessionError`): the
method's existing return type `Result<Option<...>>` already carried the method's existing return type `Result<Option<...>>` already carried the
documented spent arm, and the sibling read-shaped surfaces documented spent arm, and the sibling read-shaped surfaces
(`recv_datagram`'s spent stream) return the EOF-shape `Ok(None)` — the (`recv_datagram`'s spent stream) return the EOF-shape `Ok(None)`, the
session family's read-side posture; `SpentSessionError` stays the session family's read-side posture; `SpentSessionError` stays the
taking-methods' (`into_inner`) shape. taking-methods' (`into_inner`) shape.
- **A failed reply#2 read restores the inner** (the phase is *not* - **A failed reply#2 read restores the inner** (the phase is *not*
consumed on failure): the taken inner is returned to the session on consumed on failure): the taken inner is returned to the session on
error — the channel stays owned (reply#1 succeeded, the listener error, so the channel stays owned (reply#1 succeeded, the listener
existed, reap via `close`/`Drop`, the error-surface contract the existed, reap via `close`/`Drop`, the error-surface contract the
existing tests pin), and the session's `split()`/`close()` keep the existing tests pin), and the session's `split()`/`close()` keep the
pipe. The corruption path is the *post-success* re-entry, and that pipe. The corruption path is the *post-success* re-entry, and that
is exactly what the take-guard closes: after success the plane is is exactly what the take-guard closes: after success the plane is
taken, the spent shape holds. A post-failure re-call re-reads taken, the spent shape holds. A post-failure re-call re-reads
reply#2 against the (already-ended) session — the producer ends the reply#2 against the (already-ended) session: the producer ends the
session on every post-reply#1 failure exit, so the read EOFs the session on every post-reply#1 failure exit, so the read EOFs the
same way (the quiet-wait garbage test pins this shape, green same way (the quiet-wait garbage test pins this shape, green
unchanged). unchanged).
- **D-1 decision: option (a)** — the session's `await_inbound` is the one - **D-1 decision: option (a)**: the session's `await_inbound` is the one
normative reply#2 reader. `BindReply1::await_inbound` is **cut** (not normative reply#2 reader. `BindReply1::await_inbound` is **cut** (not
private: the take-shape makes the handle's borrowed-plane reader private: the take-shape makes the handle's borrowed-plane reader
impossible — the plane now owns the stream); `reply1()` is documented as impossible, since the plane now owns the stream); `reply1()` is documented as
a **view** (`bind_addr` + `raw_mut`, the front-door bridge seam), which a **view** (`bind_addr` + `raw_mut`, the front-door bridge seam), which
is exactly how the four existing call sites use it. `BindReply1` stays is exactly how the four existing call sites use it. `BindReply1` stays
as the typed view (its `Send` probe pinned). as the typed view (its `Send` probe pinned).
@@ -153,21 +153,21 @@ accept-failure mappings unaffected).
`use_stream` → the negotiation closure → teardown-on-err, one place for `use_stream` → the negotiation closure → teardown-on-err, one place for
the teardown-at-birth invariant. The three constructors are one-liners the teardown-at-birth invariant. The three constructors are one-liners
over it (CONNECT returns the inner; ASSOCIATE/BIND return over it (CONNECT returns the inner; ASSOCIATE/BIND return
`(addr, inner)` tuples). Zero behavior change — the full consumer + `(addr, inner)` tuples). Zero behavior change: the full consumer +
integration suites stayed green unchanged (the only touched call sites integration suites stayed green unchanged (the only touched call sites
were the two test-side ownership consequences above). were the two test-side ownership consequences above).
- **S-2 landed**: the same doc line on all three `reap` sites (CONNECT, - **S-2 landed**: the same doc line on all three `reap` sites (CONNECT,
ASSOCIATE, BIND) — the dropped `teardown_channel` `JoinHandle` is ASSOCIATE, BIND), saying the dropped `teardown_channel` `JoinHandle` is
deliberate (the producer-side pump finishes via EOF propagation, the deliberate (the producer-side pump finishes via EOF propagation, the
graceful shape; `clear_all` aborts instead, the substrate's abrupt path). graceful shape; `clear_all` aborts instead, the substrate's abrupt path).
- **Test round**: the pinning integration test - **Test round**: the pinning integration test
(`double_await_inbound_returns_the_spent_shape_and_bytes_survive` — the (`double_await_inbound_returns_the_spent_shape_and_bytes_survive`,
second call returns `Ok(None)` and the first plane's exchange completed where the second call returns `Ok(None)` and the first plane's exchange completed
uncorrupted, a fresh bind round-trips), the `reply1()`-twice view test uncorrupted, a fresh bind round-trips), the `reply1()`-twice view test
(`reply1_twice_is_a_view_and_the_reader_yields_one_plane` — repeated (`reply1_twice_is_a_view_and_the_reader_yields_one_plane`; repeated
views carry the same address, the reader yields exactly one plane, the views carry the same address, the reader yields exactly one plane, the
second reader call is the spent shape), the spent-BIND unit test second reader call is the spent shape), the spent-BIND unit test
(`spent_bind_await_inbound_returns_the_spent_shape` — the `None`-inner (`spent_bind_await_inbound_returns_the_spent_shape`, the `None`-inner
arm + `reply1`/`split` spent probes), and the `Send` probe updated arm + `reply1`/`split` spent probes), and the `Send` probe updated
(`BindSession` + owned `BindDataPlane`). The existing BIND suite stayed (`BindSession` + owned `BindDataPlane`). The existing BIND suite stayed
green unchanged apart from the one `into_inner` ownership call site green unchanged apart from the one `into_inner` ownership call site
@@ -177,7 +177,7 @@ accept-failure mappings unaffected).
## Summary ## Summary
Closed review 002's C-1 (the one reachable silent-corruption path): the Closed review 002's C-1 (the one reachable silent-corruption path): the
BIND reply#2 phase is take-guarded — `BindSession::await_inbound` takes BIND reply#2 phase is take-guarded: `BindSession::await_inbound` takes
the session's stream, the returned `BindDataPlane` owns it (the the session's stream, the returned `BindDataPlane` owns it (the
`TakenInner` pairing posture; the session stays the teardown owner), and `TakenInner` pairing posture; the session stays the teardown owner), and
a second call returns the family's spent shape `Ok(None)` with no re-read a second call returns the family's spent shape `Ok(None)` with no re-read
+38 -38
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/fix-coverage-residue id: implementation/fix-coverage-residue
name: "Tests — the review-002 coverage residue (N-8: stage exits, splits, spent arms)" name: "Tests: the review-002 coverage residue (N-8: stage exits, splits, spent arms)"
status: completed status: completed
depends_on: [implementation/fix-bind-reply2-reentry, implementation/fix-producer-gate-policy, implementation/fix-rfc1928-posture] depends_on: [implementation/fix-bind-reply2-reentry, implementation/fix-producer-gate-policy, implementation/fix-rfc1928-posture]
scope: moderate scope: moderate
@@ -11,7 +11,7 @@ level: implementation
## Description ## Description
The review-002 coverage residue — the load-bearing gaps plus the The review-002 coverage residue: the load-bearing gaps plus the
single-arm cosmetics, attributed in single-arm cosmetics, attributed in
`docs/reviews/002-protocol-core-implementation-review.md` §N-8 so `docs/reviews/002-protocol-core-implementation-review.md` §N-8 so
the next `cargo llvm-cov` run needs no re-derivation. Tests only; the next `cargo llvm-cov` run needs no re-derivation. Tests only;
@@ -22,52 +22,52 @@ vocabulary).
**The headline gaps** (the review's named priority): **The headline gaps** (the review's named priority):
1. **The datagram stage's three failure exits** 1. **The datagram stage's three failure exits**
(`src/producer.rs:374-386`, `405-414`) — the ASSOCIATE teardown (`src/producer.rs:374-386`, `405-414`): the ASSOCIATE teardown
surface, untested anywhere (the harness's egress never fails surface, untested anywhere (the harness's egress never fails
post-reply): post-reply):
- `ReplyFrameRejected` (an egress reply violating the joint - `ReplyFrameRejected` (an egress reply violating the joint
bound) bound)
- `EgressEnded` via `next_reply`'s `Err` (the relay's end) - `EgressEnded` via `next_reply`'s `Err` (the relay's end)
- `EgressEnded` via a failing `egress.send` — at minimum this - `EgressEnded` via a failing `egress.send`; at minimum this
one gets the drop-witness treatment one gets the drop-witness treatment
`egress_factory_called_once_and_dropped_with_the_stage` gives `egress_factory_called_once_and_dropped_with_the_stage` gives
the happy path the happy path
- the stage's `Io` exit (a failing write/read) - the stage's `Io` exit (a failing write/read)
2. **The two untested `split`s** (`src/consumer.rs:437`, 2. **The two untested `split`s** (`src/consumer.rs:437`,
`771-774`) — `AssociateSession::split` and `BindSession::split` `771-774`): `AssociateSession::split` and `BindSession::split`
on a *live* stream; the front-door-bridge surfaces on a *live* stream; the front-door-bridge surfaces
(consumer.md), load-bearing for the batch-2 UDP/BIND doors. One (consumer.md), load-bearing for the batch-2 UDP/BIND doors. One
test each (or the explicit "batch-2 door exercises it" note — test each (or the explicit "batch-2 door exercises it" note;
prefer the test; the doors are not here yet). prefer the test, since the doors are not here yet).
**The single-arm residue** (batch freely; each is one small test): **The single-arm residue** (batch freely; each is one small test):
3. `consumer.rs:356-359` — `send_datagram`'s spent-stream `Io` arm 3. `consumer.rs:356-359`: `send_datagram`'s spent-stream `Io` arm
(mirror the `spent_session_*` shapes; the spent-`recv` arm is (mirror the `spent_session_*` shapes; the spent-`recv` arm is
covered). covered).
4. `consumer.rs:419` — `recv_datagram`'s mid-batch later-frame `Err` 4. `consumer.rs:419`: `recv_datagram`'s mid-batch later-frame `Err`
arm (first frame `Ok`, a later one garbage → the pending `Ok`s arm (first frame `Ok`, a later one garbage → the pending `Ok`s
dropped with the session — pin the fail-loud-drops-pending dropped with the session; pin the fail-loud-drops-pending
semantics). semantics).
5. `consumer.rs:453-456` — `AssociateSession::into_inner`'s `Ok` 5. `consumer.rs:453-456`: `AssociateSession::into_inner`'s `Ok`
arm (mirror `into_inner_pairs_the_pipe_with_the_teardown_owner`). arm (mirror `into_inner_pairs_the_pipe_with_the_teardown_owner`).
6. `consumer.rs:636-642` — `BindDataPlane`'s `poll_flush`/ 6. `consumer.rs:636-642`: `BindDataPlane`'s `poll_flush`/
`poll_shutdown` passthroughs (the BIND door's half-close rides `poll_shutdown` passthroughs (the BIND door's half-close rides
these — one shutdown test). these; one shutdown test).
7. `consumer.rs:843` — `read_reply2`'s version-mismatch arm (a 7. `consumer.rs:843`: `read_reply2`'s version-mismatch arm (a
foreign producer's non-5 version reply#2 — one unit test on foreign producer's non-5 version reply#2; one unit test on
`read_reply2` directly). `read_reply2` directly).
8. `producer.rs:560` — the quiet wait's `Err(_)` (I/O error, not 8. `producer.rs:560` (the quiet wait's `Err(_)`, i.e. an I/O error, not
EOF) arm; `producer.rs:568` — the BIND reply#2 Domain-peer EOF); `producer.rs:568` (the BIND reply#2 Domain-peer
fallback (`TargetAddr::Domain(_, port)` → `0.0.0.0:port`) — one fallback (`TargetAddr::Domain(_, port)` → `0.0.0.0:port`)): one
factory returning a Domain peer pins it. factory returning a Domain peer pins it.
9. `egress.rs:72-74, 226-230, 275-279` — the three `Debug` impl 9. `egress.rs:72-74, 226-230, 275-279`: the three `Debug` impl
bodies (render once each); `error.rs:113-115` — bodies (render once each), plus `error.rs:113-115`:
`SocksEstablishmentReason`'s `Display`. Cosmetic; batch freely. `SocksEstablishmentReason`'s `Display`. Cosmetic; batch freely.
**Explicitly out of scope** (the review's accepted-uncovered **Explicitly out of scope** (the review's accepted-uncovered
posture): `rfc1928/client.rs:217-219` (`use_password_auth`'s misuse posture): `rfc1928/client.rs:217-219` (`use_password_auth`'s misuse
arm — review-001-era accepted-uncovered with a translated-error arm: review-001-era accepted-uncovered with a translated-error
note) and the fork's accepted-uncovered blocks. Do not chase them. note) and the fork's accepted-uncovered blocks. Do not chase them.
If any arm became unreachable by the code-fix tasks (e.g. the C-1 If any arm became unreachable by the code-fix tasks (e.g. the C-1
@@ -78,7 +78,7 @@ through an artificial shape.
## Acceptance Criteria ## Acceptance Criteria
- [x] The stage's failure exits tested (`ReplyFrameRejected`, - [x] The stage's failure exits tested (`ReplyFrameRejected`,
`EgressEnded` both arms, `Io`) — the `send`-failure arm with `EgressEnded` both arms, `Io`), with the `send`-failure arm using
the drop-witness assertion the drop-witness assertion
- [x] `AssociateSession::split` + `BindSession::split` tested on a - [x] `AssociateSession::split` + `BindSession::split` tested on a
live stream (or the batch-2-door note recorded per arm) live stream (or the batch-2-door note recorded per arm)
@@ -94,7 +94,7 @@ through an artificial shape.
## References ## References
- docs/reviews/002-protocol-core-implementation-review.md §N-8 (the - docs/reviews/002-protocol-core-implementation-review.md §N-8 (the
attributed line list — the task's source of truth) attributed line list, the task's source of truth)
- tests/harness.rs (the topology + egress shapes to extend) - tests/harness.rs (the topology + egress shapes to extend)
- src/producer.rs `egress_factory_called_once_and_dropped_with_the_stage` - src/producer.rs `egress_factory_called_once_and_dropped_with_the_stage`
(the drop-witness pattern) (the drop-witness pattern)
@@ -127,7 +127,7 @@ through an artificial shape.
treatment: `udp_egress_send_failure_ends_the_association_and_ treatment: `udp_egress_send_failure_ends_the_association_and_
drops_the_egress` (a full ASSOCIATE conversation, one datagram in, drops_the_egress` (a full ASSOCIATE conversation, one datagram in,
the egress refuses it, EOF reaches the client, the stage exits, the egress refuses it, EOF reaches the client, the stage exits,
and the drop flag flips — teardown-at-birth on the failure path). and the drop flag flips: teardown-at-birth on the failure path).
The tests ride `FailStream` (a poll-failing stream) and The tests ride `FailStream` (a poll-failing stream) and
`OneReplyEgress`/`RelayEndedEgress`/`ParkedEgress` (stage-shaped `OneReplyEgress`/`RelayEndedEgress`/`ParkedEgress` (stage-shaped
egress fakes), all inside the producer test module. egress fakes), all inside the producer test module.
@@ -135,52 +135,52 @@ through an artificial shape.
yields_live_halves_and_survives_the_round_trip` and `bind_split_ yields_live_halves_and_survives_the_round_trip` and `bind_split_
yields_live_halves_pre_reply2` exercise `tokio::io::split` on a yields_live_halves_pre_reply2` exercise `tokio::io::split` on a
live `Socks5Stream<BiStream>` built by a new test helper live `Socks5Stream<BiStream>` built by a new test helper
(`build_skip_auth_bi_stream` — two duplexes joined via (`build_skip_auth_bi_stream`: two duplexes joined via
`BiStream::from_joined`, the same shape `open_and_negotiate` `BiStream::from_joined`, the same shape `open_and_negotiate`
assembles): raw framed bytes pass through both directions on the assembles): raw framed bytes pass through both directions on the
borrowed halves, and (BIND) the split leaves the take-guard borrowed halves, and (BIND) the split leaves the take-guard
untouched — `await_inbound` still reads the reply#2 phase after untouched; `await_inbound` still reads the reply#2 phase after
the halves are dropped. No "batch-2 door exercises it" note the halves are dropped. No "batch-2 door exercises it" note
needed; the tests stand alone. needed; the tests stand alone.
- **The single-arm residue, test by test:** - **The single-arm residue, test by test:**
`send_datagram_on_a_spent_stream_is_the_io_arm` (the spent-stream `send_datagram_on_a_spent_stream_is_the_io_arm` (the spent-stream
`Io` arm — and the spent-`recv` shape asserted in the same test: `Io` arm, with the spent-`recv` shape asserted in the same test:
`Ok(None)`, the family's read-side posture), `Ok(None)`, the family's read-side posture),
`recv_datagram_mid_batch_later_frame_err_drops_the_pending_oks` `recv_datagram_mid_batch_later_frame_err_drops_the_pending_oks`
(first frame `Ok`, a later one garbage → the pending `Ok`s dropped (first frame `Ok`, a later one garbage → the pending `Ok`s dropped
with the session, the fail-loud-drops-pending semantics pinned), with the session, the fail-loud-drops-pending semantics pinned),
`associate_into_inner_pairs_the_stream_with_the_teardown_owner` `associate_into_inner_pairs_the_stream_with_the_teardown_owner`
(the `TakenAssociateInner` Ok arm — the CONNECT (the `TakenAssociateInner` Ok arm, the CONNECT
`into_inner_pairs_the_pipe_with_the_teardown_owner` shape's `into_inner_pairs_the_pipe_with_the_teardown_owner` shape's
ASSOCIATE mirror), ASSOCIATE mirror),
`bind_data_plane_poll_flush_and_poll_shutdown_pass_through` `bind_data_plane_poll_flush_and_poll_shutdown_pass_through`
(write → `poll_flush` → `poll_shutdown` — the bytes reach the peer (write → `poll_flush` → `poll_shutdown`: the bytes reach the peer
and the EOF sentinel follows), and the EOF sentinel follows),
`read_reply2_rejects_a_foreign_producers_version` (a non-5 `read_reply2_rejects_a_foreign_producers_version` (a non-5
version reply#2 → `UnsupportedSocksVersion(0x04)`, loud), version reply#2 → `UnsupportedSocksVersion(0x04)`, loud),
`bind_quiet_wait_read_error_ends_the_wait_cleanly` (the quiet `bind_quiet_wait_read_error_ends_the_wait_cleanly` (the quiet
wait's `Err(_)` arm driven directly on `bind_quiet_wait_and_serve` wait's `Err(_)` arm driven directly on `bind_quiet_wait_and_serve`,
— a read *error* ends the wait `Ok(())` and the accept future where a read *error* ends the wait `Ok(())` and the accept future
drops with its owned state, the client-gone contract's drops with its owned state, the client-gone contract's
error-shaped twin), `bind_domain_peer_falls_back_to_the_ error-shaped twin), `bind_domain_peer_falls_back_to_the_
unspecified_address` (a foreign accept handle carrying unspecified_address` (a foreign accept handle carrying
`TargetAddr::Domain(_, port)` peer → reply#2 carries `TargetAddr::Domain(_, port)` peer → reply#2 carries
`0.0.0.0:port`, data plane live), `0.0.0.0:port`, data plane live),
`debug_impls_render_the_handle_surfaces` (the three egress.rs `debug_impls_render_the_handle_surfaces` (the three egress.rs
`Debug` bodies — TargetHandle non-exhaustive, BindTargetHandle `Debug` bodies: TargetHandle non-exhaustive, BindTargetHandle
names the peer, BindAccept names the bind address), names the peer, BindAccept names the bind address),
`establishment_reason_display_renders_the_wire_code` (the `establishment_reason_display_renders_the_wire_code` (the
`SocksEstablishmentReason` `Display` — each variant renders as its `SocksEstablishmentReason` `Display`; each variant renders as its
canonical wire code), `associate_debug_renders_channel_liveness_ canonical wire code), `associate_debug_renders_channel_liveness_
and_pending` (the ASSOCIATE + BIND session `Debug` bodies), and_pending` (the ASSOCIATE + BIND session `Debug` bodies),
`spent_associate_close_reaps_a_no_op` (the `close`-on-spent arm), `spent_associate_close_reaps_a_no_op` (the `close`-on-spent arm),
and `establisher_records_identity_and_defaults_the_establishment` and `establisher_records_identity_and_defaults_the_establishment`
extended with the `None`-witness arm. extended with the `None`-witness arm.
- **A quiet-wait select-shape discovery (test-only note):** - **A quiet-wait select-shape discovery (test-only note):**
`tokio::select!` polls branches in random order — a read branch `tokio::select!` polls branches in random order: a read branch
that errors on its *first* poll can win before the accept branch that errors on its *first* poll can win before the accept branch
is ever polled, dropping the accept future at zero polls (the is ever polled, dropping the accept future at zero polls (the
owned state never constructed; a drop flag would never fire — the owned state never constructed; a drop flag would never fire, and the
first cut of the test caught this flake). The test's `FailReadStream` first cut of the test caught this flake). The test's `FailReadStream`
parks once (with a wake) then errors on the second poll so both parks once (with a wake) then errors on the second poll so both
branches are guaranteed started; the assertion then holds branches are guaranteed started; the assertion then holds
@@ -192,11 +192,11 @@ through an artificial shape.
now `418` (the `inner.as_mut()` else arm after the C-1/D-1 now `418` (the `inner.as_mut()` else arm after the C-1/D-1
changes); it is covered by the same test that pins the spent-send changes); it is covered by the same test that pins the spent-send
arm. `consumer.rs:937` (the spent-bind test's `err().map()` arm. `consumer.rs:937` (the spent-bind test's `err().map()`
diagnostic closure) stays uncovered — it only evaluates on `Err`, diagnostic closure) stays uncovered, since it only evaluates on `Err`,
and the spent shape is `Ok(None)` by design (the C-1 posture); and the spent shape is `Ok(None)` by design (the C-1 posture);
cosmetic, not chased. cosmetic, not chased.
- **Reachability check for the arm-became-unreachable clause**: no - **Reachability check for the arm-became-unreachable clause**: no
N-8 arm was made unreachable by the three code-fix tasks — every N-8 arm was made unreachable by the three code-fix tasks; every
named arm still exists at its (shifted) location and is now named arm still exists at its (shifted) location and is now
tested. The explicit out-of-scope items stayed out of scope: tested. The explicit out-of-scope items stayed out of scope:
`rfc1928/client.rs:217-219` remains accepted-uncovered (with its `rfc1928/client.rs:217-219` remains accepted-uncovered (with its
@@ -204,7 +204,7 @@ through an artificial shape.
blocks. blocks.
- **Residual uncovered lines after this task (none new)**: producer - **Residual uncovered lines after this task (none new)**: producer
.rs 188-189 (the pump handler's `accept_bi` substrate-failure log .rs 188-189 (the pump handler's `accept_bi` substrate-failure log
arm — pre-existing, not in N-8's list; untestable without a arm: pre-existing, not in N-8's list, and untestable without a
broken `Connection` outside alkcall's test surface), broken `Connection` outside alkcall's test surface),
rfc1928/client.rs 217-219 + the fork internals (accepted- rfc1928/client.rs 217-219 + the fork internals (accepted-
uncovered per review-001/002), wire.rs/mod.rs/target_addr.rs uncovered per review-001/002), wire.rs/mod.rs/target_addr.rs
+27 -27
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/fix-hygiene-residue id: implementation/fix-hygiene-residue
name: "Fix — error-schema declaration + small hygiene residue (review-002 D-3, N-3, N-9)" name: "Fix: error-schema declaration + small hygiene residue (review-002 D-3, N-3, N-9)"
status: completed status: completed
depends_on: [] depends_on: []
scope: narrow scope: narrow
@@ -12,20 +12,20 @@ level: implementation
## Description ## Description
The small mechanical residue that does not ride either code-fix The small mechanical residue that does not ride either code-fix
task — one doc-vs-code drift with a real fix, two one-line task: one doc-vs-code drift with a real fix, plus two one-line
postures (`docs/reviews/002-protocol-core-implementation-review.md`): postures (`docs/reviews/002-protocol-core-implementation-review.md`):
1. **D-3 (minor) — `error_schemas` is `vec![]` while the module doc 1. **D-3 (minor): `error_schemas` is `vec![]` while the module doc
claims the ADR-049 §4 + ADR-016 contract** (`src/params.rs:94-96` claims the ADR-049 §4 + ADR-016 contract** (`src/params.rs:94-96`
vs `:117`). The typed vocabulary works behaviorally vs `:117`). The typed vocabulary works behaviorally
(`SocksOpenError::reason` parses `details.reason` off the wire — (`SocksOpenError::reason` parses `details.reason` off the wire,
tested), but a discovery consumer reading `error_schemas` from tested), but a discovery consumer reading `error_schemas` from
the registry sees no declared failure contract for the registry sees no declared failure contract for
`channels/socks5/sub`, and the doc describes a contract the spec `channels/socks5/sub`, and the doc describes a contract the spec
does not carry. does not carry.
Fix per the review's lean: **declare the `ErrorDefinition`** — Fix per the review's lean: **declare the `ErrorDefinition`**,
port alktty's (the first protocol crate's precedent, porting alktty's (the first protocol crate's precedent,
`/workspace/@alkdev/alktty/src/channels.rs:165-208`: the `/workspace/@alkdev/alktty/src/channels.rs:165-208`: the
`channel:open_failed` definition with the `{ reason, message }` `channel:open_failed` definition with the `{ reason, message }`
schema, pinned by a test asserting the declaration at schema, pinned by a test asserting the declaration at
@@ -35,33 +35,33 @@ postures (`docs/reviews/002-protocol-core-implementation-review.md`):
registry's `FORBIDDEN` class). One test mirrors alktty's registry's `FORBIDDEN` class). One test mirrors alktty's
declaration pin. If the declare-side turns out to hit a declaration pin. If the declare-side turns out to hit a
substrate constraint (the registry rejects the definition, say), substrate constraint (the registry rejects the definition, say),
fall back to the honest reword ("empty — the typed contract is fall back to the honest reword ("empty, since the typed contract is
behavioral via `SocksOpenError::reason`; the `ErrorDefinition` behavioral via `SocksOpenError::reason`; the `ErrorDefinition`
pass is crate-family-wide, matching alktunnels, pending") and pass is crate-family-wide, matching alktunnels, pending") and
record why. record why.
2. **N-3 (observation) — `DatagramReader::feed`'s let-else at 2. **N-3 (observation): `DatagramReader::feed`'s let-else at
`src/wire.rs:229-231` is structurally unreachable** (0 `src/wire.rs:229-231` is structurally unreachable** (0
executions; the block above always sets `current` before the executions; the block above always sets `current` before the
let-else when `buf.len() >= 2`, and the loop broke at `:223` let-else when `buf.len() >= 2`, and the loop broke at `:223`
otherwise). Restructure per the review's sketch — match on the otherwise). Restructure per the review's sketch: match on the
already-known-`Some` state, or re-order to drop the else. Zero already-known-`Some` state, or re-order to drop the else. Zero
behavior change; the full wire suite stays green unchanged. behavior change; the full wire suite stays green unchanged.
3. **N-9 (observation) — `is_mid_frame` is public but consumed 3. **N-9 (observation): `is_mid_frame` is public but consumed
nowhere outside the codec's own tests** (`src/wire.rs:253-260`). nowhere outside the codec's own tests** (`src/wire.rs:253-260`).
Keep (a batch-2 front-door bridge driving its own reader loop Keep (a batch-2 front-door bridge driving its own reader loop
would use it) — one doc line naming the anticipated consumer would use it), and add one doc line naming the anticipated consumer
("the doors") so the next dead-surface sweep does not flag it ("the doors") so the next dead-surface sweep does not flag it
without this note. without this note.
## Acceptance Criteria ## Acceptance Criteria
- [x] `socks5_open_spec` declares the `channel:open_failed` - [x] `socks5_open_spec` declares the `channel:open_failed`
`ErrorDefinition` (the alktty-ported schema, adapted) — or the `ErrorDefinition` (the alktty-ported schema, adapted), or the
doc is reworded to the honest empty posture with the reason doc is reworded to the honest empty posture with the reason
recorded recorded
- [x] A test pins the declaration (mirroring alktty's) — or the - [x] A test pins the declaration (mirroring alktty's), or the
reworded doc matches the spec exactly reworded doc matches the spec exactly
- [x] The `feed` let-else is restructured; zero behavior change - [x] The `feed` let-else is restructured; zero behavior change
(the wire suite green unchanged) (the wire suite green unchanged)
@@ -73,23 +73,23 @@ postures (`docs/reviews/002-protocol-core-implementation-review.md`):
- docs/reviews/002-protocol-core-implementation-review.md §D-3, §N-3, - docs/reviews/002-protocol-core-implementation-review.md §D-3, §N-3,
§N-9 §N-9
- alkcall ADR-049 §4 + ADR-016 (the typed error contract — - alkcall ADR-049 §4 + ADR-016 (the typed error contract:
`/workspace/@alkdev/alkcall/docs/architecture/decisions/`) `/workspace/@alkdev/alkcall/docs/architecture/decisions/`)
- alktty precedent: `/workspace/@alkdev/alktty/src/channels.rs:165-208` - alktty precedent: `/workspace/@alkdev/alktty/src/channels.rs:165-208`
(the `ErrorDefinition` + schema) and `:790` (the pinning test) (the `ErrorDefinition` + schema) and `:790` (the pinning test)
- alktunnels sibling: `/workspace/@alkdev/alktunnels/src/params.rs` - alktunnels sibling: `/workspace/@alkdev/alktunnels/src/params.rs`
(the empty-vec posture — the fallback's precedent) (the empty-vec posture, the fallback's precedent)
## Notes ## Notes
- **D-3 — declared, not reworded.** The review's lean (the declare - **D-3: declared, not reworded.** The review's lean (the declare
side) worked with no substrate constraint: the registry accepted side) worked with no substrate constraint: the registry accepted
the definition on the first try. The schema is alktty's the definition on the first try. The schema is alktty's
`channel:open_failed` block `channel:open_failed` block
(`/workspace/@alkdev/alktty/src/channels.rs:165-208`), adapted: (`/workspace/@alkdev/alktty/src/channels.rs:165-208`), adapted:
the `reason` enum carries all five ADR-049 §3 codes the `reason` enum carries all five ADR-049 §3 codes
(`dial_failed` / `unknown_resource` / `resource_shortage` / (`dial_failed` / `unknown_resource` / `resource_shortage` /
`handler_error` / `timeout`) — alktty's enum has four because its `handler_error` / `timeout`); alktty's enum has four because its
establisher has no `resource_shortage` path, while this crate's establisher has no `resource_shortage` path, while this crate's
declared vocabulary is chosen to match declared vocabulary is chosen to match
`SocksEstablishmentReason`'s parse table exactly (the typed view a `SocksEstablishmentReason`'s parse table exactly (the typed view a
@@ -99,12 +99,12 @@ postures (`docs/reviews/002-protocol-core-implementation-review.md`):
establisher could produce any of the five). The `description` establisher could produce any of the five). The `description`
text is rewritten to this crate's establishment story: the text is rewritten to this crate's establishment story: the
registry gate classes first (`FORBIDDEN` / `INVALID_INPUT` / registry gate classes first (`FORBIDDEN` / `INVALID_INPUT` /
`channel:too_many_channels` — the ACL runs before the `channel:too_many_channels`; the ACL runs before the
establisher, ADR-006), then the establishment vocabulary, then establisher, ADR-006), then the establishment vocabulary, then
the no-phantom-channel property. One judgment call worth noting the no-phantom-channel property. One judgment call worth noting
for the review round: the declared enum is *wider* than the for the review round: the declared enum is *wider* than the
default producer's behavioral surface (which produces none of the default producer's behavioral surface (which produces none of the
five today — the dial is in-stream, post-open). That is the five today, because the dial is in-stream, post-open). That is the
correct direction to over-declare: `error_schemas` discloses the correct direction to over-declare: `error_schemas` discloses the
op's contract shape (any registration of this op, per ADR-049 op's contract shape (any registration of this op, per ADR-049
§4), and the enum is the wire's parse table §4), and the enum is the wire's parse table
@@ -114,10 +114,10 @@ postures (`docs/reviews/002-protocol-core-implementation-review.md`):
Beyond mirroring alktty's declaration pin Beyond mirroring alktty's declaration pin
(`:780-807`), `open_spec_declares_the_open_failed_error_definition` (`:780-807`), `open_spec_declares_the_open_failed_error_definition`
asserts each enum value round-trips through asserts each enum value round-trips through
`SocksEstablishmentReason::parse`/`as_str` — the declared schema `SocksEstablishmentReason::parse`/`as_str`, so the declared schema
and the typed vocabulary cannot drift silently (the exact D-3 and the typed vocabulary cannot drift silently (the exact D-3
drift class, pinned shut). drift class, pinned shut).
- **N-3 — restructured to a live-else shape, not the review's - **N-3: restructured to a live-else shape, not the review's
sketch.** Both of the review's alternates were considered: the sketch.** Both of the review's alternates were considered: the
`let Some(&mut (declared, ref mut acc))` match has the same `let Some(&mut (declared, ref mut acc))` match has the same
dead-arm problem (it must fire *after* the header read, which is dead-arm problem (it must fire *after* the header read, which is
@@ -125,13 +125,13 @@ postures (`docs/reviews/002-protocol-core-implementation-review.md`):
sketch inverts the loop's exit order. The landed shape folds the sketch inverts the loop's exit order. The landed shape folds the
header read into the let-else's else arm header read into the let-else's else arm
(`let Some((declared, acc)) = self.current.as_mut() else { (`let Some((declared, acc)) = self.current.as_mut() else {
<read header; continue; }`) — every branch is live, the <read header; continue; }`), where every branch is live, the
"current set above" invariant is structural (the else arm is the "current set above" invariant is structural (the else arm is the
only place `current` is set), and the body is untouched. Zero only place `current` is set), and the body is untouched. Zero
behavior change: the full wire suite passes unmodified. behavior change: the full wire suite passes unmodified.
- **N-9 — kept with the note.** One doc paragraph naming the - **N-9: kept with the note.** One doc paragraph naming the
anticipated batch-2 consumer (a front-door bridge driving its own anticipated batch-2 consumer (a front-door bridge driving its own
reader loop — the doors) and citing the review finding. reader loop, i.e. the doors) and citing the review finding.
## Summary ## Summary
@@ -139,17 +139,17 @@ All three residue items closed in one pass (`src/params.rs`,
`src/wire.rs`): `src/wire.rs`):
1. **D-3**: `socks5_open_spec` now declares the 1. **D-3**: `socks5_open_spec` now declares the
`channel:open_failed` `ErrorDefinition` — alktty-ported schema `channel:open_failed` `ErrorDefinition` (an alktty-ported schema
(`{ reason (enum of the five ADR-049 §3 codes), message }`, (`{ reason (enum of the five ADR-049 §3 codes), message }`,
both required), description adapted to this crate's gate classes both required), description adapted to this crate's gate classes
+ establishment story. Module doc updated from claim to fact. + establishment story. Module doc updated from claim to fact.
Pinned by `open_spec_declares_the_open_failed_error_definition` Pinned by `open_spec_declares_the_open_failed_error_definition`
(declaration + enum round-trips through (declaration + enum round-trips through
`SocksEstablishmentReason` — schema and typed vocabulary pinned `SocksEstablishmentReason`, pinning schema and typed vocabulary
drift-free). drift-free).
2. **N-3**: `DatagramReader::feed`'s structurally-unreachable 2. **N-3**: `DatagramReader::feed`'s structurally-unreachable
let-else restructured into a live-else shape (header read in the let-else restructured into a live-else shape (header read in the
else arm); zero behavior change — the wire suite green unchanged. else arm); zero behavior change: the wire suite green unchanged.
3. **N-9**: `is_mid_frame` kept; its doc names the anticipated 3. **N-9**: `is_mid_frame` kept; its doc names the anticipated
consumer (the batch-2 front-door bridges) so future dead-surface consumer (the batch-2 front-door bridges) so future dead-surface
sweeps don't flag it. sweeps don't flag it.
@@ -1,6 +1,6 @@
--- ---
id: implementation/fix-producer-gate-policy id: implementation/fix-producer-gate-policy
name: "Fix — producer BIND gate policy + register coverage + stage doc lines (review-002 C-3, D-2, S-1, N-2)" name: "Fix: producer BIND gate policy + register coverage + stage doc lines (review-002 C-3, D-2, S-1, N-2)"
status: completed status: completed
depends_on: [] depends_on: []
scope: narrow scope: narrow
@@ -16,32 +16,32 @@ unexercised assembly surface, plus the two doc-line residues that
ride the same files ride the same files
(`docs/reviews/002-protocol-core-implementation-review.md`): (`docs/reviews/002-protocol-core-implementation-review.md`):
1. **C-3 (minor) — `serve_socks5_over` derives `can_bind` from 1. **C-3 (minor): `serve_socks5_over` derives `can_bind` from
`accept.is_some()`** (`src/producer.rs:630-648`): factory-wired ⇒ `accept.is_some()`** (`src/producer.rs:630-648`): factory-wired ⇒
BIND allowed, with no way to say "factory wired but gate closed" BIND allowed, with no way to say "factory wired but gate closed"
on the substrate-free entry. The pump handler (the channels path) on the substrate-free entry. The pump handler (the channels path)
resolves the gate from the per-call opener identity (ADR-006 — resolves the gate from the per-call opener identity (ADR-006,
correct); the substrate-free entry derives it from seam which is correct); the substrate-free entry derives it from seam
*presence*. For the batch-2 `local` backend — the documented *presence*. For the batch-2 `local` backend, which is the documented
consumer of this entry — the local door's BIND authorization has consumer of this entry, the local door's BIND authorization has
no seam to plug into except factory presence, which is exactly no seam to plug into except factory presence, which is exactly
the shape that invites "listener configured" ⇒ "listener the shape that invites "listener configured" ⇒ "listener
permitted" (the conflation ADR-006 exists to prevent). permitted" (the conflation ADR-006 exists to prevent).
Fix per the review's sketch: widen the substrate-free entry's Fix per the review's sketch: widen the substrate-free entry's
gate to an explicit policy input — `serve_socks5_over_with_gate` gate to an explicit policy input, `serve_socks5_over_with_gate`
or a builder field `.bind_policy(bool)` defaulting to or a builder field `.bind_policy(bool)` defaulting to
`accept.is_some()` for backward-compat with the landed tests. `accept.is_some()` for backward-compat with the landed tests.
The pump handler's identity-derived gate is unaffected. One The pump handler's identity-derived gate is unaffected. One
test: a producer with an accept factory + explicit test: a producer with an accept factory + explicit
`can_bind=false` refuses 0x02. `can_bind=false` refuses 0x02.
2. **D-2 (minor) — `Socks5Producer::register` and the builder's 2. **D-2 (minor): `Socks5Producer::register` and the builder's
`egress`/`accept` setters are unexercised public surface** (0% `egress`/`accept` setters are unexercised public surface** (0%
covered; the harness registers via the free function covered; the harness registers via the free function
`register_socks5_openable` instead, and the builder test drives `register_socks5_openable` instead, and the builder test drives
only `serve_socks5_over`). The spec'd assembly shape only `serve_socks5_over`). The spec'd assembly shape
(`producer.md` §Wiring) is the one assembly path with no test — (`producer.md` §Wiring) is the one assembly path with no test, so
a signature drift or delegation bug would pass the suite. a signature drift or delegation bug would pass the suite.
Fix: one integration test wiring a full Fix: one integration test wiring a full
@@ -49,7 +49,7 @@ ride the same files
through `.register(...)` and serving all three commands over the through `.register(...)` and serving all three commands over the
harness topology. This is the natural home for C-3's gate test. harness topology. This is the natural home for C-3's gate test.
3. **S-1 (minor) — the datagram stage's residual hot-`Ok(None)` 3. **S-1 (minor): the datagram stage's residual hot-`Ok(None)`
posture, named as a decision.** The R-1 yield-guard makes the posture, named as a decision.** The R-1 yield-guard makes the
stage livelock-safe; the residual is a *misbehaving* egress stage livelock-safe; the residual is a *misbehaving* egress
returning `Ok(None)` at full speed = a yielding busy-wait returning `Ok(None)` at full speed = a yielding busy-wait
@@ -58,7 +58,7 @@ ride the same files
would penalize the compliant path). One doc line on would penalize the compliant path). One doc line on
`run_datagram_stage` naming the residual. `run_datagram_stage` naming the residual.
4. **N-2 (observation) — one line in wire.md §UDP ASSOCIATE**: the 4. **N-2 (observation): one line in wire.md §UDP ASSOCIATE.** The
per-datagram `egress.send` is awaited inline (the stage per-datagram `egress.send` is awaited inline (the stage
interleaves sends with reply drains); a slow egress applies interleaves sends with reply drains); a slow egress applies
backpressure to the association, not to other channels. The backpressure to the association, not to other channels. The
@@ -87,7 +87,7 @@ ride the same files
- docs/reviews/002-protocol-core-implementation-review.md §C-3, §D-2, - docs/reviews/002-protocol-core-implementation-review.md §C-3, §D-2,
§S-1, §N-2 §S-1, §N-2
- docs/architecture/decisions/006-access-control.md (the gate - docs/architecture/decisions/006-access-control.md (the gate
posture — "listener configured" ≠ "listener permitted") posture, "listener configured" ≠ "listener permitted")
- docs/architecture/producer.md §Wiring (the spec'd assembly shape) - docs/architecture/producer.md §Wiring (the spec'd assembly shape)
- docs/architecture/wire.md §UDP ASSOCIATE (the N-2 line's home) - docs/architecture/wire.md §UDP ASSOCIATE (the N-2 line's home)
- tasks/implementation/core-producer-bind.md (the landed gate) - tasks/implementation/core-producer-bind.md (the landed gate)
@@ -95,18 +95,18 @@ ride the same files
## Notes ## Notes
- **C-3 shape chosen: the builder field** (`.bind_policy(bool)`), - **C-3 shape chosen: the builder field** (`.bind_policy(bool)`),
not a separate `serve_socks5_over_with_gate` entry — the review's not a separate `serve_socks5_over_with_gate` entry; the review's
sketch allowed either; a builder field keeps the entry's signature sketch allowed either, but a builder field keeps the entry's signature
untouched (one `serve_socks5_over` shape), rides the existing untouched (one `serve_socks5_over` shape), rides the existing
builder chain, and the policy is resolved once at `build()` builder chain, and the policy is resolved once at `build()`
(`self.bind_policy.unwrap_or(self.accept.is_some())` — (`self.bind_policy.unwrap_or(self.accept.is_some())`:
`Option<bool>` on the builder, `bool` on the built producer). The `Option<bool>` on the builder, `bool` on the built producer). The
free function `serve_socks5_over` (already gate-explicit) and the free function `serve_socks5_over` (already gate-explicit) and the
pump handler (identity-derived, ADR-006) are untouched. pump handler (identity-derived, ADR-006) are untouched.
- **D-2 vehicle: a new `RegisterMode::ProducerBuilder`** in - **D-2 vehicle: a new `RegisterMode::ProducerBuilder`** in
`tests/harness.rs` wiring the full builder chain `tests/harness.rs` wiring the full builder chain
(`.dial().egress().accept()` + the optional `.bind_policy()`) (`.dial().egress().accept()` + the optional `.bind_policy()`)
through `Socks5Producer::register` — the harness's registration through `Socks5Producer::register`: the harness's registration
block gains a third arm (same `ChannelCore` + block gains a third arm (same `ChannelCore` +
`register_openable_with_establisher` shape as the others, via the `register_openable_with_establisher` shape as the others, via the
delegated method). The integration test delegated method). The integration test
@@ -116,7 +116,7 @@ ride the same files
builder-delegated establisher ran). C-3's gate tests live in the builder-delegated establisher ran). C-3's gate tests live in the
producer unit suite (`builder_bind_policy_false_refuses_bind_ producer unit suite (`builder_bind_policy_false_refuses_bind_
while_connect_associate_serve`, `builder_default_bind_policy_ while_connect_associate_serve`, `builder_default_bind_policy_
follows_accept_presence`) — the substrate-free entry is a unit follows_accept_presence`): the substrate-free entry is a unit
shape; the builder-through-register surface is the integration shape; the builder-through-register surface is the integration
one. one.
- **The `bind_policy(false)` test** pins: 0x02 on BIND with the - **The `bind_policy(false)` test** pins: 0x02 on BIND with the
@@ -126,7 +126,7 @@ ride the same files
**The default test** pins the backward-compat reading: accept **The default test** pins the backward-compat reading: accept
wired + policy unwired ⇒ BIND serves (factory ran); accept wired + policy unwired ⇒ BIND serves (factory ran); accept
unwired + policy unwired ⇒ 0x02. unwired + policy unwired ⇒ 0x02.
- **S-1/N-2 are doc lines only** (per the review's recommendation — - **S-1/N-2 are doc lines only** (per the review's recommendation:
posture notes, not backoff/budget code): a residual paragraph on posture notes, not backoff/budget code): a residual paragraph on
`run_datagram_stage` (hot-`Ok(None)` = a yielding busy-wait, `run_datagram_stage` (hot-`Ok(None)` = a yielding busy-wait,
bounded by the egress's own correctness, accepted-uncovered by bounded by the egress's own correctness, accepted-uncovered by
@@ -136,7 +136,7 @@ ride the same files
## Summary ## Summary
All four items landed (C-3, D-2, S-1, N-2). `Socks5ProducerBuilder` All four items landed (C-3, D-2, S-1, N-2). `Socks5ProducerBuilder`
gained `.bind_policy(bool)` — the substrate-free entry's explicit gained `.bind_policy(bool)`, the substrate-free entry's explicit
BIND gate, defaulting to the accept seam's presence (the built BIND gate, defaulting to the accept seam's presence (the built
producer resolves it to a plain `bool`); `Socks5Producer:: producer resolves it to a plain `bool`); `Socks5Producer::
serve_socks5_over` now consults the policy instead of serve_socks5_over` now consults the policy instead of
@@ -147,12 +147,12 @@ factory un-called while CONNECT/ASSOCIATE serve; the
presence-default both ways). A new harness mode presence-default both ways). A new harness mode
(`RegisterMode::ProducerBuilder`) plus one integration test wires (`RegisterMode::ProducerBuilder`) plus one integration test wires
the full builder through `.register(...)` and serves all three the full builder through `.register(...)` and serves all three
commands end to end — the spec'd assembly shape is no longer commands end to end; the spec'd assembly shape is no longer
unexercised. Doc lines: `run_datagram_stage` names the hot-`Ok(None)` unexercised. Doc lines: `run_datagram_stage` names the hot-`Ok(None)`
residual (S-1); wire.md §UDP ASSOCIATE carries the inline-send residual (S-1); wire.md §UDP ASSOCIATE carries the inline-send
backpressure bound (N-2). Verification: `cargo test` 352 green (125 backpressure bound (N-2). Verification: `cargo test` 352 green (125
lib + 38 associate + 41 bind + 40 connect + 41 integration + 30 lib + 38 associate + 41 bind + 40 connect + 41 integration + 30
harness + 37 differential + 3 doctests, 2 ignored — the two new harness + 37 differential + 3 doctests, 2 ignored, since the two new
unit tests and the one new integration test added to the review's unit tests and the one new integration test added to the review's
348 baseline), `cargo clippy --all-targets -- -D warnings` clean, 348 baseline), `cargo clippy --all-targets -- -D warnings` clean,
`cargo fmt --check` clean, `cargo doc --no-deps` clean, both wasm `cargo fmt --check` clean, `cargo doc --no-deps` clean, both wasm
+33 -33
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/fix-rfc1928-posture id: implementation/fix-rfc1928-posture
name: "Fix — rfc1928 keep-or-cut pass + honest refusal vocabulary (review-002 N-1, N-4)" name: "Fix: rfc1928 keep-or-cut pass + honest refusal vocabulary (review-002 N-1, N-4)"
status: completed status: completed
depends_on: [] depends_on: []
scope: narrow scope: narrow
@@ -11,13 +11,13 @@ level: implementation
## Description ## Description
The review-001 keep-or-cut triggers have fired — the producer layer The review-001 keep-or-cut triggers have fired (the producer layer
landed without consuming the anticipated surfaces — and the landed without consuming the anticipated surfaces), and the
in-stream refusal vocabulary mislabels policy refusals as bugs. in-stream refusal vocabulary mislabels policy refusals as bugs.
One posture pass over both One posture pass over both
(`docs/reviews/002-protocol-core-implementation-review.md`): (`docs/reviews/002-protocol-core-implementation-review.md`):
1. **N-1 (minor) — the fork's consumerless machinery.** The 1. **N-1 (minor): the fork's consumerless machinery.** The
producer drives `negotiate_auth` with `&[NoAuthentication]` only; producer drives `negotiate_auth` with `&[NoAuthentication]` only;
the seams carry their own error vocabularies; `pump_bidi` the seams carry their own error vocabularies; `pump_bidi`
replaced `transfer`. Per the review's disposition: replaced `transfer`. Per the review's disposition:
@@ -27,22 +27,22 @@ One posture pass over both
- `StandardAuthentication` + `allow_no_auth` + - `StandardAuthentication` + `allow_no_auth` +
`auth_method_enums!`'s generated enum `auth_method_enums!`'s generated enum
(`src/rfc1928/server.rs:448-482`; the macro itself can stay or (`src/rfc1928/server.rs:448-482`; the macro itself can stay or
go with it — nothing else uses it) go with it, as nothing else uses it)
- `skip_auth_this_is_not_rfc_compliant` - `skip_auth_this_is_not_rfc_compliant`
(`src/rfc1928/server.rs:185-187`; the consumer typestate's (`src/rfc1928/server.rs:185-187`; the consumer typestate's
`skip_auth` config covers the in-tree need) `skip_auth` config covers the in-tree need)
- `CheckResult`'s `Option`/`Result` impls - `CheckResult`'s `Option`/`Result` impls
(`src/rfc1928/server.rs:162-172`; keep `bool` — re-add with a (`src/rfc1928/server.rs:162-172`; keep `bool`, and re-add with a
typed-rejection design if a door ever wants it) typed-rejection design if a door ever wants it)
**Keep with note** (the hand-port-map posture, ADR-013): **Keep with note** (the hand-port-map posture, ADR-013):
- `ConnectError` (constructed nowhere, but the hand-port map's - `ConnectError` (constructed nowhere, but the hand-port map's
companion — its reply-code mapping is what a future port companion; its reply-code mapping is what a future port
reuses; the module doc already says so) reuses, which the module doc already says so)
- `transfer`/`wait_on_tcp` (differential-suite-consumed; their - `transfer`/`wait_on_tcp` (differential-suite-consumed; their
semantics stay pinned) semantics stay pinned)
- `SocksServerError::Bug` (now consumed by the producer's - `SocksServerError::Bug` (now consumed by the producer's
refusal sites — see N-4 for its display) refusal sites; see N-4 for its display)
The coverage blocks resolve automatically (the accepted-uncovered The coverage blocks resolve automatically (the accepted-uncovered
posture was exactly these blocks). Update the module docs where posture was exactly these blocks). Update the module docs where
@@ -50,19 +50,19 @@ One posture pass over both
`ConnectError` note, the `auth_method_enums!` doc's "generate a `ConnectError` note, the `auth_method_enums!` doc's "generate a
similar enum" line) and the differential suite's comment that similar enum" line) and the differential suite's comment that
references `StandardAuthentication`'s method ordering references `StandardAuthentication`'s method ordering
(`tests/rfc1928-differential.rs:103` — reword to the RFC's (`tests/rfc1928-differential.rs:103`; reword to the RFC's
ordering rationale, not the cut type). ordering rationale, not the cut type).
2. **N-4 (observation) — the task-log refusal vocabulary mislabels 2. **N-4 (observation): the task-log refusal vocabulary mislabels
policy refusals.** Five in-stream refusal sites return errors the policy refusals.** Five in-stream refusal sites return errors the
wire never sees (the wire got its RFC-faithful reply already; wire never sees (the wire got its RFC-faithful reply already;
these are log/observability signals only), but the vocabulary these are log/observability signals only), but the vocabulary
lies: the BIND command gate returns lies: the BIND command gate returns
`SocksServerError::AuthenticationRejected` (an ACL refusal is not `SocksServerError::AuthenticationRejected` (an ACL refusal is not
"authentication rejected" — `src/producer.rs:230-233`), and four "authentication rejected"; `src/producer.rs:230-233`), and four
seam refusals return `Bug("... refused in-stream")` (a policy seam refusals return `Bug("... refused in-stream")` (a policy
refusal is not a bug; the display literally says `BUG: ...` — refusal is not a bug, but the display literally says `BUG: ...` at
`src/producer.rs:262-264`, `302-304`, `483-485`, `541-543`). `src/producer.rs:262-264`, `302-304`, `483-485`, `541-543`)..
An operator grepping `BUG:` chases ghosts that are An operator grepping `BUG:` chases ghosts that are
working-as-designed refusals. working-as-designed refusals.
@@ -70,17 +70,17 @@ One posture pass over both
(`SocksServerError::Refused(&'static str)`-shaped) for the (`SocksServerError::Refused(&'static str)`-shaped) for the
in-stream seam refusals and the command gate; use it at the five in-stream seam refusals and the command gate; use it at the five
refusal sites. The wire bytes are untouched (these variants never refusal sites. The wire bytes are untouched (these variants never
reach the wire — the RFC reply went out first). Alternatively reach the wire, since the RFC reply went out first). Alternatively
keep `Bug` and rename its display — but the dedicated variant is keep `Bug` and rename its display, but the dedicated variant is
the honest shape; pick one and record it. Mind the fork's the honest shape; pick one and record it. Mind the fork's
provenance posture (convention 17): this is an owned-code provenance posture (convention 17): this is an owned-code
extension, so extend the module doc's carried-changes note if the extension, so extend the module doc's carried-changes note if the
variant list grows. variant list grows.
Tests: the five refusal sites' existing tests assert the mapped Tests: the five refusal sites' existing tests assert the mapped
reply codes (unchanged — the wire is untouched); update any test reply codes (unchanged, since the wire is untouched); update any test
asserting the *session-error* variant at those sites (the harness's asserting the *session-error* variant at those sites (the harness's
`Bug` at `tests/harness.rs:387` is its own shape — leave unless it `Bug` at `tests/harness.rs:387` is its own shape; leave it unless it
wants the new variant); one test pinning the new variant's wants the new variant); one test pinning the new variant's
display/shape if the vocabulary is table-tested elsewhere. display/shape if the vocabulary is table-tested elsewhere.
@@ -89,12 +89,12 @@ display/shape if the vocabulary is table-tested elsewhere.
- [x] `StandardAuthentication`, `allow_no_auth`, the generated - [x] `StandardAuthentication`, `allow_no_auth`, the generated
enum, `skip_auth_this_is_not_rfc_compliant`, and enum, `skip_auth_this_is_not_rfc_compliant`, and
`CheckResult`'s `Option`/`Result` impls are cut; `bool` stays `CheckResult`'s `Option`/`Result` impls are cut; `bool` stays
- [x] `ConnectError`, `transfer`/`wait_on_tcp`, `Bug` kept — each - [x] `ConnectError`, `transfer`/`wait_on_tcp`, `Bug` kept, each
with its keep-note current (module docs name the posture) with its keep-note current (module docs name the posture)
- [x] The differential suite stays green (the cut machinery was not - [x] The differential suite stays green (the cut machinery was not
load-bearing for it; the `:103` comment reworded) load-bearing for it; the `:103` comment reworded)
- [x] The five in-stream refusal sites carry the honest vocabulary - [x] The five in-stream refusal sites carry the honest vocabulary
(one variant or the renamed display — the choice recorded); (one variant or the renamed display, with the recorded choice);
the wire bytes are untouched (the reply-code assertions stay the wire bytes are untouched (the reply-code assertions stay
green) green)
- [x] The fork's provenance notice is extended, not replaced, if the - [x] The fork's provenance notice is extended, not replaced, if the
@@ -109,16 +109,16 @@ display/shape if the vocabulary is table-tested elsewhere.
this closes out; the C-1 misleading-display note) this closes out; the C-1 misleading-display note)
- docs/architecture/decisions/013-targeted-fork.md (the hand-port - docs/architecture/decisions/013-targeted-fork.md (the hand-port
map vs no-speculative-surface distinction) map vs no-speculative-surface distinction)
- docs/architecture/decisions/006-access-control.md (the gate — - docs/architecture/decisions/006-access-control.md (the gate:
policy refusals are not authentication failures) policy refusals are not authentication failures)
- src/rfc1928/LICENSE (the provenance notice — extend, never - src/rfc1928/LICENSE (the provenance notice; extend, never
replace) replace)
## Notes ## Notes
- **N-4 shape chosen: the dedicated variant** — - **N-4 shape chosen: the dedicated variant**, namely
`SocksServerError::Refused(&'static str)`, display `"Refused: `SocksServerError::Refused(&'static str)` with display `"Refused:
{0}"` — not the `Bug` display rename. The review's sketch favored {0}"`, not the `Bug` display rename. The review's sketch favored
the dedicated variant ("the honest shape"), and the vocabulary the dedicated variant ("the honest shape"), and the vocabulary
split is real: `Bug` stays upstream-verbatim (`BUG: {0}`, its split is real: `Bug` stays upstream-verbatim (`BUG: {0}`, its
"should be unreachable" contract intact) while policy refusals "should be unreachable" contract intact) while policy refusals
@@ -127,13 +127,13 @@ display/shape if the vocabulary is table-tested elsewhere.
`Refused("dial refused in-stream")`, `Refused("dial refused in-stream")`,
`Refused("egress refused in-stream")`, `Refused("egress refused in-stream")`,
`Refused("bind factory refused in-stream")`, `Refused("bind factory refused in-stream")`,
`Refused("bind accept refused in-stream")` — same `Refused("bind accept refused in-stream")`, same
`&'static str` payload shape as `Bug`, zero wire impact (the RFC `&'static str` payload shape as `Bug`, zero wire impact (the RFC
reply went out before each `Err`). reply went out before each `Err`).
- **The harness's `Bug` site was migrated too** (the task said - **The harness's `Bug` site was migrated too** (the task said
"leave unless it wants the new variant"): `tests/harness.rs`'s "leave unless it wants the new variant"): `tests/harness.rs`'s
minimal CONNECT producer returned `Bug("dial refused in-stream")` minimal CONNECT producer returned `Bug("dial refused in-stream")`,
— the same working-as-designed refusal shape, so it takes the new the same working-as-designed refusal shape, so it takes the new
variant for vocabulary consistency across the log surface. No variant for vocabulary consistency across the log surface. No
test asserts that harness error's variant, so nothing else test asserts that harness error's variant, so nothing else
moved. moved.
@@ -142,7 +142,7 @@ display/shape if the vocabulary is table-tested elsewhere.
fall-through loop in `to_reply_error_mapping_table` (alongside fall-through loop in `to_reply_error_mapping_table` (alongside
`Bug`), and a new test pins the display string and the `Bug`), and a new test pins the display string and the
fall-through mapping (`refused_variant_display_is_honest_and_ fall-through mapping (`refused_variant_display_is_honest_and_
wire_invisible`) — the task's "one test pinning the new variant's wire_invisible`), covering the task's "one test pinning the new variant's
display/shape". display/shape".
- **Provenance extended, not replaced** (convention 17): the - **Provenance extended, not replaced** (convention 17): the
`server.rs` module doc gained a "Post-fork owned changes" `server.rs` module doc gained a "Post-fork owned changes"
@@ -151,7 +151,7 @@ display/shape if the vocabulary is table-tested elsewhere.
post-fork changes" list under the deliberate modifications and a post-fork changes" list under the deliberate modifications and a
`last_updated` line. The bundled `src/rfc1928/LICENSE` notice `last_updated` line. The bundled `src/rfc1928/LICENSE` notice
already carries the "modifications after the fork point" clause already carries the "modifications after the fork point" clause
generically — no change needed there. generically, so no change was needed there.
- **Keep-notes made current**: `stream.rs`'s `ConnectError` note - **Keep-notes made current**: `stream.rs`'s `ConnectError` note
records that the trigger fired and the disposition is **keep** records that the trigger fired and the disposition is **keep**
(was: "cut it then"); `transfer`/`wait_on_tcp`'s differential- (was: "cut it then"); `transfer`/`wait_on_tcp`'s differential-
@@ -159,7 +159,7 @@ display/shape if the vocabulary is table-tested elsewhere.
is the same paragraph (upstream-verbatim, its display untouched). is the same paragraph (upstream-verbatim, its display untouched).
- **The differential suite's `:103` comment** reworded to the - **The differential suite's `:103` comment** reworded to the
RFC-ordering rationale (the server picks by its own preference, RFC-ordering rationale (the server picks by its own preference,
more-secure-first) — no cut-type reference remains anywhere. more-secure-first), and no cut-type reference remains anywhere.
- **The `AuthMethod` trait doc** was updated for the macro cut - **The `AuthMethod` trait doc** was updated for the macro cut
(upstream's "use enums for sets" line → a custom selection is a (upstream's "use enums for sets" line → a custom selection is a
slice of method structs) and the module doc's extraction-time slice of method structs) and the module doc's extraction-time
@@ -172,8 +172,8 @@ Both items landed. **N-1 (the cut)**: `auth_method_enums!` (the
macro, nothing else used it), `StandardAuthentication` + macro, nothing else used it), `StandardAuthentication` +
`StandardAuthenticationStarted` + `allow_no_auth`, `StandardAuthenticationStarted` + `allow_no_auth`,
`skip_auth_this_is_not_rfc_compliant`, and `CheckResult`'s `skip_auth_this_is_not_rfc_compliant`, and `CheckResult`'s
`Option`/`Result` impls are gone from `src/rfc1928/server.rs` — `Option`/`Result` impls are gone from `src/rfc1928/server.rs`, since
the producer-replaced machinery, per ADR-013's they were the producer-replaced machinery per ADR-013's
no-speculative-surface posture. Kept with notes: `ConnectError` no-speculative-surface posture. Kept with notes: `ConnectError`
(hand-port companion, `stream.rs` note updated to record the fired (hand-port companion, `stream.rs` note updated to record the fired
trigger + keep disposition), `transfer`/`wait_on_tcp` trigger + keep disposition), `transfer`/`wait_on_tcp`
@@ -1,6 +1,6 @@
--- ---
id: implementation/fix-test-helper-consolidation id: implementation/fix-test-helper-consolidation
name: "Tests — consolidate the duplicated harness shapes (review-002 N-5)" name: "Tests: consolidate the duplicated harness shapes (review-002 N-5)"
status: completed status: completed
depends_on: [implementation/fix-coverage-residue] depends_on: [implementation/fix-coverage-residue]
scope: narrow scope: narrow
@@ -12,15 +12,15 @@ level: implementation
## Description ## Description
The producer's test module re-implements the harness's shapes four The producer's test module re-implements the harness's shapes four
times — ~250 structural-duplicate test lines times (about ~250 structural-duplicate test lines;
(`docs/reviews/002-protocol-core-implementation-review.md` §N-5). (`docs/reviews/002-protocol-core-implementation-review.md` §N-5).
The cause is legitimate (unit tests in `src/` cannot import The cause is legitimate (unit tests in `src/` cannot import
`tests/harness.rs` — separate crates), so the fix is the standard `tests/harness.rs` are separate crates), so the fix is the standard
shared-module pattern, not a harness import: shared-module pattern, not a harness import:
- `src/producer.rs:954-987` (`QueueEgress`), `1237-1274` - `src/producer.rs:954-987` (`QueueEgress`), `1237-1274`
(`HotNoneEgress`), `1321-1359` (`PureHotNoneEgress`), (`HotNoneEgress`), `1321-1359` (`PureHotNoneEgress`),
`1504-1553` (`Witness` egress) — four `Egress` impls of the same `1504-1553` (`Witness` egress): four `Egress` impls of the same
queue shape queue shape
- `src/producer.rs:792-814` (`greet`/`write_request`/`read_reply`) - `src/producer.rs:792-814` (`greet`/`write_request`/`read_reply`)
vs `tests/harness.rs:876-933` (the same trio) vs `tests/harness.rs:876-933` (the same trio)
@@ -34,7 +34,7 @@ keeps the two harness shapes from diverging further (they already
have: the harness's `ChanEgress` and the producer's `QueueEgress` have: the harness's `ChanEgress` and the producer's `QueueEgress`
differ only in the mutex wrapper). differ only in the mutex wrapper).
**Not collapsible** (deliberately different — leave them): **Not collapsible** (deliberately different; leave them):
`HotNoneEgress`/`PureHotNoneEgress` (R-1's two distinct violation `HotNoneEgress`/`PureHotNoneEgress` (R-1's two distinct violation
shapes) and the `Witness` egress (carries the drop flag). The shapes) and the `Witness` egress (carries the drop flag). The
consolidation targets the *byte-near-identical* shapes only: consolidation targets the *byte-near-identical* shapes only:
@@ -54,7 +54,7 @@ introduce fresh duplicates of harness shapes, fold them here too.
`read_reply` trio are single-sourced (the byte-near-identical `read_reply` trio are single-sourced (the byte-near-identical
shapes) shapes)
- [x] `HotNoneEgress`/`PureHotNoneEgress`/`Witness` remain - [x] `HotNoneEgress`/`PureHotNoneEgress`/`Witness` remain
test-local (deliberately distinct shapes — not collapsed) test-local (deliberately distinct shapes, not collapsed)
- [x] Zero behavior change: the full suite green with the same test - [x] Zero behavior change: the full suite green with the same test
count (minus any folded duplicates, recorded in the Notes) count (minus any folded duplicates, recorded in the Notes)
- [x] `cargo test` green; clippy/fmt clean; both wasm checks clean - [x] `cargo test` green; clippy/fmt clean; both wasm checks clean
@@ -77,7 +77,7 @@ introduce fresh duplicates of harness shapes, fold them here too.
"../tests/common/mod.rs"] pub(crate) mod test_common;` plus "../tests/common/mod.rs"] pub(crate) mod test_common;` plus
`#[cfg(test)] extern crate self as alksocks;`. The `#[path]` `#[cfg(test)] extern crate self as alksocks;`. The `#[path]`
from a nested module (`src/producer.rs`'s tests) does not from a nested module (`src/producer.rs`'s tests) does not
resolve — `#[path]` is relative to the containing module's resolve: `#[path]` is relative to the containing module's
*directory* (`src/producer/tests/` for an inline child), and *directory* (`src/producer/tests/` for an inline child), and
`#[path]` rejects non-literal expressions (no `#[path]` rejects non-literal expressions (no
`concat!(env!("CARGO_MANIFEST_DIR"), ...)`), so the only clean `concat!(env!("CARGO_MANIFEST_DIR"), ...)`), so the only clean
@@ -87,14 +87,14 @@ introduce fresh duplicates of harness shapes, fold them here too.
paths (`alksocks::…`) the integration suite resolves; inside paths (`alksocks::…`) the integration suite resolves; inside
the lib test crate the alias makes the same paths resolve. the lib test crate the alias makes the same paths resolve.
`src/producer.rs` consumes it as `use crate::test_common as `src/producer.rs` consumes it as `use crate::test_common as
common;` — no `tests/harness.rs` import from `src/`. common;` with no `tests/harness.rs` import from `src/`.
- **The integration suite reaches the same copy through - **The integration suite reaches the same copy through
`harness`**: `tests/harness.rs` declares `#[path = `harness`**: `tests/harness.rs` declares `#[path =
"common/mod.rs"] pub mod common;` and re-exports the consumed "common/mod.rs"] pub mod common;` and re-exports the consumed
subset under the names its callers already use (`echo_egress` subset under the names its callers already use (`echo_egress`
for `echo_egress_factory`, `udp_frame`/`udp_unframe` for for `echo_egress_factory`, `udp_frame`/`udp_unframe` for
`datagram_frame`/`decode_datagram`, `datagram_frame`/`decode_datagram`,
`witnessing_echo_dial` for `echo_dial_witnessed`) — the `witnessing_echo_dial` for `echo_dial_witnessed`), and the
per-test `use harness::{…}` surface is unchanged, so the per-test `use harness::{…}` surface is unchanged, so the
consumer-* suites needed no edits at all. consumer-* suites needed no edits at all.
- **Single-sourced shapes** (the task's named targets plus the - **Single-sourced shapes** (the task's named targets plus the
@@ -105,16 +105,16 @@ introduce fresh duplicates of harness shapes, fold them here too.
wrapper); the producer keeps a one-line `echo_dial(targets)` wrapper); the producer keeps a one-line `echo_dial(targets)`
adapter since its witness arg is per-test. adapter since its witness arg is per-test.
- The refusing dial (all five `SocksDialError` variants - The refusing dial (all five `SocksDialError` variants
reconstructed per call) — three copies → one. reconstructed per call): three copies → one.
- The queue egress + echo factory: harness `ChanEgress` + - The queue egress + echo factory: harness `ChanEgress` +
producer's `QueueEgress` (the review's mutex-wrapper-only producer's `QueueEgress` (the review's mutex-wrapper-only
divergence) + `echo_egress`/`echo_egress_factory` → common's divergence) + `echo_egress`/`echo_egress_factory` → common's
`QueueEgress` + `echo_egress_factory` (the mutex-wrapped `QueueEgress` + `echo_egress_factory` (the mutex-wrapped
shape both had converged on; `next_reply` parks on `recv()` — shape both had converged on, with `next_reply` parking on `recv()`;
the readiness contract preserved verbatim). the readiness contract preserved verbatim).
- The refusing egress factory → common's `refusing_egress` - The refusing egress factory → common's `refusing_egress`
(harness copy; the producer's inline (harness copy; the producer's inline
`Err(EgressError::Unreachable(…))` closures stayed — they are `Err(EgressError::Unreachable(…))` closures stayed, since they are
one-liners, not the shape). one-liners, not the shape).
- The raw RFC 1928 client trio: harness `RawRfcClient`'s - The raw RFC 1928 client trio: harness `RawRfcClient`'s
`greet`/`request`/`read_reply` bodies + producer's `greet`/`request`/`read_reply` bodies + producer's
@@ -134,24 +134,24 @@ introduce fresh duplicates of harness shapes, fold them here too.
names). names).
- The parked accept factory: harness-era producer - The parked accept factory: harness-era producer
`parked_accept_factory` + core-integration's `parked_accept` → `parked_accept_factory` + core-integration's `parked_accept` →
common's `parked_accept_factory` (bind addr `127.0.0.1:2121` common's `parked_accept_factory` (bind addr `127.0.0.1:2121`,
— the ECHO_BIND_PORT carrier; core-integration imports it the ECHO_BIND_PORT carrier; core-integration imports it
aliased as `parked_accept`). aliased as `parked_accept`).
- **Deliberately not collapsed** (per the task; verified - **Deliberately not collapsed** (per the task; verified
test-local): producer's `HotNoneEgress` (mutex-yielding hot test-local): producer's `HotNoneEgress` (mutex-yielding hot
`Ok(None)`) and `PureHotNoneEgress` (zero-yield — R-1's two `Ok(None)`) and `PureHotNoneEgress` (zero-yield, R-1's two
distinct violation shapes), `Witness`/`FailingSendWitness` (the distinct violation shapes), `Witness`/`FailingSendWitness` (the
drop flags), core-integration's `HotNoneEgress` (same-named but drop flags), core-integration's `HotNoneEgress` (same-named but
the concurrency-load variant), and the refusing the concurrency-load variant), and the refusing
accept-factory/accept-future pair (typed per error class; used by accept-factory/accept-future pair (typed per error class; used by
the harness and consumer-bind only). Also left: harness's the harness and consumer-bind only). Also left: harness's
`echo_accept` + producer's `bind_accept_factory` (the echo-pipe `echo_accept` + producer's `bind_accept_factory` (the echo-pipe
accept shape) — the producer copy carries the per-test requested- accept shape; the producer copy carries the per-test requested-
target witness, so the bodies differ beyond the mutex wrapper; target witness, so the bodies differ beyond the mutex wrapper;
noted for the next touch but out of this task's named scope. noted for the next touch but out of this task's named scope.
- **Zero behavior change, same test count**: 145 lib / 38 associate - **Zero behavior change, same test count**: 145 lib / 38 associate
/ 41 bind / 40 connect / 41 integration / 30 harness / 37 / 41 bind / 40 connect / 41 integration / 30 harness / 37
differential / 3 doc-tests — byte-identical to the coverage differential / 3 doc-tests, byte-identical to the coverage
task's post-N-8 population (no folded duplicates; the coverage task's post-N-8 population (no folded duplicates; the coverage
task's new tests were all consumer-side unit shapes with no task's new tests were all consumer-side unit shapes with no
harness duplicates to fold). Net −307 lines (81 insertions / 388 harness duplicates to fold). Net −307 lines (81 insertions / 388
@@ -169,7 +169,7 @@ introduce fresh duplicates of harness shapes, fold them here too.
## Summary ## Summary
Consolidated review 002 N-5's duplicated harness shapes into Consolidated review 002 N-5's duplicated harness shapes into
`tests/common/mod.rs` — the standard `mod common` pattern, mounted `tests/common/mod.rs`, the standard `mod common` pattern, mounted
from `src/lib.rs` (`#[cfg(test)] #[path] pub(crate) mod test_common` from `src/lib.rs` (`#[cfg(test)] #[path] pub(crate) mod test_common`
+ `extern crate self as alksocks`) for the lib test crate and from + `extern crate self as alksocks`) for the lib test crate and from
`tests/harness.rs` (`#[path = "common/mod.rs"]` + name-preserving `tests/harness.rs` (`#[path = "common/mod.rs"]` + name-preserving
@@ -183,7 +183,7 @@ stream type; the harness's `RawRfcClient` delegates to it), the
datagram frame codec helpers (three copies → one), and the parked datagram frame codec helpers (three copies → one), and the parked
accept factory. Left deliberately distinct: the hot-none egress accept factory. Left deliberately distinct: the hot-none egress
variants (R-1's two shapes), the drop-witness egresses, and the variants (R-1's two shapes), the drop-witness egresses, and the
typed accept-refusal factories. Zero behavior change — full suite typed accept-refusal factories. Zero behavior change (full suite
green with the identical test population (375 + 2 ignored doc), no green with the identical test population (375 + 2 ignored doc), no
tests folded. Verification: `cargo test` + `--all-features` green, tests folded. Verification: `cargo test` + `--all-features` green,
clippy `-D warnings` clean (native + wasm), fmt clean, doc clean, clippy `-D warnings` clean (native + wasm), fmt clean, doc clean,
+24 -24
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/local-backend-producer id: implementation/local-backend-producer
name: Producer-side local backend — the producer's front door (vanilla clients → a locally-served SOCKS5 resource) name: Producer-side local backend (the producer's front door, vanilla clients → a locally-served SOCKS5 resource)
status: completed status: completed
depends_on: [implementation/local-providers] depends_on: [implementation/local-providers]
scope: narrow scope: narrow
@@ -12,48 +12,48 @@ level: implementation
## Description ## Description
The producer-side local backend (feature `local`): the same door The producer-side local backend (feature `local`): the same door
shape in the other direction — the producer serves from a real shape in the other direction; the producer serves from a real
listener, so vanilla clients reach a *locally-served* SOCKS5 resource listener, so vanilla clients reach a *locally-served* SOCKS5 resource
(producer.md §Producer-Side Local Backend). This is the shape where (producer.md §Producer-Side Local Backend). This is the shape where
the SOCKS5 service rides a real `TcpListener` instead of (or alongside) the SOCKS5 service rides a real `TcpListener` instead of (or alongside)
channels: each accepted TCP connection is handed to the landed channels: each accepted TCP connection is handed to the landed
substrate-free entry `Socks5Producer::serve_socks5_over` directly — substrate-free entry `Socks5Producer::serve_socks5_over` directly, so
the RFC conversation runs on the accepted socket, the seams dial the RFC conversation runs on the accepted socket while the seams dial
locally. locally.
Deliverables: Deliverables:
1. **`src/local/` additions** (feature `local`) — the producer-side 1. **`src/local/` additions** (feature `local`), the producer-side
listener shape: listener shape:
- A `serve_local`-shaped constructor on the local module (exact - A `serve_local`-shaped constructor on the local module (exact
name at implementation; producer.md names no symbol — record name at implementation; producer.md names no symbol; record
the choice in Notes): takes a `Socks5Producer` (the landed the choice in Notes): takes a `Socks5Producer` (the landed
builder type), a bind address, and the door-policy knobs (the builder type), a bind address, and the door-policy knobs (the
idle-first-byte timeout + max-concurrent-sessions cap — the idle-first-byte timeout + max-concurrent-sessions cap, the
same two knobs the consumer door carries, ADR-014's same two knobs the consumer door carries, per ADR-014's
accept-path defense; an accepted socket is not a channel). accept-path defense; an accepted socket is not a channel).
- The accept loop: bind the listener, per accepted connection - The accept loop: bind the listener, per accepted connection
spawn `producer.serve_socks5_over(stream)`; per-connection spawn `producer.serve_socks5_over(stream)`; per-connection
failures are logged + isolated (the loop continues). The failures are logged + isolated (the loop continues). The
listener's lifetime is the caller's (a `close`/Drop shape — listener's lifetime is the caller's (a `close`/Drop shape per
the alktunnels `TunnelListener` teardown posture). the alktunnels `TunnelListener` teardown posture).
- **This is a front door for the producer side**: the door - **This is a front door for the producer side**: the door
surface (NoAuth raw pipe vs RFC 1929 cred-bearing, ADR-005) is surface (NoAuth raw pipe vs RFC 1929 cred-bearing, ADR-005) is
the consumer door's code shape. For the producer-side backend the consumer door's code shape. For the producer-side backend
the NoAuth shape is the primary one (the producer IS the RFC the NoAuth shape is the primary one (the producer IS the RFC
conversation here — `serve_socks5_over` runs `accept_no_auth` conversation here, since `serve_socks5_over` runs `accept_no_auth`
in-stream); a cred-bearing variant would wrap in-stream); a cred-bearing variant would wrap
`accept_password_auth` before `serve_socks5_over` — land the `accept_password_auth` before `serve_socks5_over`. Land the
NoAuth shape; the cred-bearing producer-side door is a NoAuth shape; the cred-bearing producer-side door is a
documented follow-up unless a test drives it (record the documented follow-up unless a test drives it (record the
decision in Notes). decision in Notes).
- The BIND command gate on this path is the builder's - The BIND command gate on this path is the builder's
`bind_policy` (the landed `Socks5ProducerBuilder::bind_policy` `bind_policy` (the landed `Socks5ProducerBuilder::bind_policy`,
— the substrate-free entry's gate); document that the i.e. the substrate-free entry's gate); document that the
per-caller gate (the pump handler's identity-derived one) does per-caller gate (the pump handler's identity-derived one) does
not apply here — this path has no open-op identity. not apply here because this path has no open-op identity.
2. **Tests** (`tests/local-backend-producer.rs`): a vanilla RFC 1928 2. **Tests** (`tests/local-backend-producer.rs`): a vanilla RFC 1928
client against the locally-served producer — CONNECT round-trip client against the locally-served producer: a CONNECT round-trip
through `local::tcp_dial()` seams (the whole stack on real through `local::tcp_dial()` seams (the whole stack on real
sockets); BIND through the local backend (the faithful-RFC sockets); BIND through the local backend (the faithful-RFC
variant: real reply#1/reply#2 addresses); the idle-first-byte variant: real reply#1/reply#2 addresses); the idle-first-byte
@@ -67,11 +67,11 @@ Deliverables:
--check` clean --check` clean
- [ ] Default wasm checks stay green (backend code is `local`-gated) - [ ] Default wasm checks stay green (backend code is `local`-gated)
- [ ] The backend serves via `Socks5Producer::serve_socks5_over` (the - [ ] The backend serves via `Socks5Producer::serve_socks5_over` (the
landed substrate-free entry) — no new protocol-layer code landed substrate-free entry), with no new protocol-layer code
- [ ] The accept path owns the idle defense + session cap (the two - [ ] The accept path owns the idle defense + session cap (the two
knobs) — pinned by tests knobs), pinned by tests
- [ ] Per-connection failure isolation — pinned by test - [ ] Per-connection failure isolation, pinned by test
- [ ] The `bind_policy` gate is honored on this path — pinned by test - [ ] The `bind_policy` gate is honored on this path, pinned by test
## References ## References
@@ -87,7 +87,7 @@ Deliverables:
## Notes ## Notes
- **Symbol choice**: the constructor is `ProducerDoor` (+ `ProducerDoorBuilder`/ - **Symbol choice**: the constructor is `ProducerDoor` (+ `ProducerDoorBuilder`/
`ProducerDoorGuard`) in `src/local/producer_door.rs` — the consumer door's `ProducerDoorGuard`) in `src/local/producer_door.rs`, named on the consumer door's
sibling name (`FrontDoor`/`UdpFrontDoor`), not `serve_local`: the surface is a sibling name (`FrontDoor`/`UdpFrontDoor`), not `serve_local`: the surface is a
builder + accept-loop object (`serve()` binds, `serve_on(listener)` is the builder + accept-loop object (`serve()` binds, `serve_on(listener)` is the
test/assembly seam over an existing listener), matching the consumer door's test/assembly seam over an existing listener), matching the consumer door's
@@ -95,16 +95,16 @@ Deliverables:
- **Door surface decision**: the NoAuth shape landed; the cred-bearing - **Door surface decision**: the NoAuth shape landed; the cred-bearing
producer-side door is a documented follow-up (no test drives it). On this producer-side door is a documented follow-up (no test drives it). On this
path the producer IS the RFC conversation (`serve_socks5_over` runs path the producer IS the RFC conversation (`serve_socks5_over` runs
`accept_no_auth` in-stream) — there is no door-side protocol half to `accept_no_auth` in-stream); there is no door-side protocol half to
re-originate; a cred-bearing variant would wrap `accept_password_auth` re-originate; a cred-bearing variant would wrap `accept_password_auth`
before handing the stream to `serve_socks5_over`. before handing the stream to `serve_socks5_over`.
- **Guard shape** (the alktunnels `TunnelListener` teardown posture): - **Guard shape** (the alktunnels `TunnelListener` teardown posture):
`serve`/`serve_on` return a `ProducerDoorGuard`; `close()` ends the accept `serve`/`serve_on` return a `ProducerDoorGuard`; `close()` ends the accept
loop gracefully and awaits the loop task; a Drop aborts it best-effort loop gracefully and awaits the loop task; a Drop aborts it best-effort
(sync, never awaits). In-flight sessions are never force-closed by the (sync, never awaits). In-flight sessions are never force-closed by the
guard — they own their sockets (per-connection isolation cuts both ways). guard, as they own their sockets (per-connection isolation cuts both ways).
- **BIND gate documentation**: the per-caller gate (the pump handler's - **BIND gate documentation**: the per-caller gate (the pump handler's
identity-derived one) does not apply here — this path has no open-op identity-derived one) does not apply here because this path has no open-op
identity; the builder's `bind_policy` is the whole gate, pinned by the identity; the builder's `bind_policy` is the whole gate, pinned by the
`bind_policy(false)` test (0x02 refusal, CONNECT still serves). `bind_policy(false)` test (0x02 refusal, CONNECT still serves).
- **No protocol-layer code**: the accept loop rides - **No protocol-layer code**: the accept loop rides
@@ -115,7 +115,7 @@ Deliverables:
## Summary ## Summary
Landed the producer-side local backend (feature `local`): `ProducerDoor` Landed the producer-side local backend (feature `local`): `ProducerDoor`
(`src/local/producer_door.rs`, exported from the crate root) — a real (`src/local/producer_door.rs`, exported from the crate root): a real
`TcpListener` front door handing every accepted connection to the landed `TcpListener` front door handing every accepted connection to the landed
`Socks5Producer::serve_socks5_over` (NoAuth in-stream RFC conversation; `Socks5Producer::serve_socks5_over` (NoAuth in-stream RFC conversation;
`local::tcp_dial()` / `udp_egress()` / `bind_accept()` dial locally). The `local::tcp_dial()` / `udp_egress()` / `bind_accept()` dial locally). The
+37 -37
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/local-providers id: implementation/local-providers
name: Local — the socket-backed seam providers (tcp_dial / udp_egress / bind_accept) name: Local (the socket-backed seam providers, tcp_dial / udp_egress / bind_accept)
status: completed status: completed
depends_on: [] depends_on: []
scope: moderate scope: moderate
@@ -12,42 +12,42 @@ level: implementation
## Description ## Description
The first batch-2 task: the `local` feature's first-party socket-backed The first batch-2 task: the `local` feature's first-party socket-backed
providers — the three seams' faithful-RFC constructors (ADR-008's providers, the three seams' faithful-RFC constructors (ADR-008's
local variants, ADR-009's feature gate). These are the assembly-layer local variants, ADR-009's feature gate). These are the assembly-layer
convenience constructors over the landed seam types convenience constructors over the landed seam types
(`src/egress.rs`); the protocol layer never sees them. (`src/egress.rs`); the protocol layer never sees them.
Deliverables: Deliverables:
1. **`Cargo.toml` feature wiring** — `local = ["tokio/net"]` (the 1. **`Cargo.toml` feature wiring**: `local = ["tokio/net"]` (the
feature's own socket providers; `socket2` only if a provider feature's own socket providers; `socket2` only if a provider
actually needs it — the POCs ran tokio-only, so default to NOT actually needs it, though the POCs ran tokio-only, so default to NOT
adding it). tokio stays `default-features = false` in adding it). tokio stays `default-features = false` in
`[dependencies]`; the `net` feature rides `local` alone (ADR-009). `[dependencies]`; the `net` feature rides `local` alone (ADR-009).
Verify the default graph stays net-free (`cargo tree` spot-check) Verify the default graph stays net-free (`cargo tree` spot-check)
and the default wasm checks stay green. and the default wasm checks stay green.
2. **`src/local/mod.rs`** (feature `local`) — the provider 2. **`src/local/mod.rs`** (feature `local`): the provider
constructors, named per producer.md §Wiring: constructors, named per producer.md §Wiring:
- `tcp_dial()` → `DialFn`: `TcpStream::connect` per CONNECT target; - `tcp_dial()` → `DialFn`: `TcpStream::connect` per CONNECT target;
domain targets resolve here (the ssh `-D` semantic — ADR-012: domain targets resolve here (the ssh `-D` semantic; per ADR-012
resolution is the provider's job); the connected stream splits resolution is the provider's job); the connected stream splits
into the `TargetHandle` halves (`Box<dyn AsyncRead + Send + Sync into the `TargetHandle` halves (`Box<dyn AsyncRead + Send + Sync
+ Unpin>` — `TcpStream::into_split` halves are Send+Sync, verify + Unpin>`; `TcpStream::into_split` halves are Send+Sync, so verify
the boxing compiles). Dial failures map to `SocksDialError` the boxing compiles). Dial failures map to `SocksDialError`
faithfully (`ConnectionRefused` for connect-refused, faithfully (`ConnectionRefused` for connect-refused,
`NetworkUnreachable`/`HostUnreachable` per `io::ErrorKind`, `NetworkUnreachable`/`HostUnreachable` per `io::ErrorKind`,
`NotAllowed` never synthesized by this provider). `NotAllowed` never synthesized by this provider).
- `udp_egress()` → `EgressFactory`: one real `UdpSocket` per - `udp_egress()` → `EgressFactory`: one real `UdpSocket` per
association (bind `127.0.0.1:0` — the assembly-layer bind association (bind `127.0.0.1:0`, the assembly-layer bind
decision, explicit here), per-datagram `send_to`/`recv_from`, no decision, made explicit here), per-datagram `send_to`/`recv_from`, no
flow table; domain destinations resolve at the egress flow table; domain destinations resolve at the egress
(`resolve_target`-shaped — port the POC #2 `LocalEgress` (`resolve_target`-shaped; port the POC #2 `LocalEgress`
helper). **The readiness contract is load-bearing**: `next_reply` helper). **The readiness contract is load-bearing**: `next_reply`
parks on `sock.readable().await` then `try_recv_from` parks on `sock.readable().await` then `try_recv_from`
(`WouldBlock` → `Ok(None)` spurious-wakeup shape; a real async (`WouldBlock` → the `Ok(None)` spurious-wakeup shape; a real async
park — never a hot `Ok(None)`); replies carry *inner* bytes park, never a hot `Ok(None)`); replies carry *inner* bytes
(`TargetAddr::to_be_bytes() + data` — the sender's address per (`TargetAddr::to_be_bytes() + data`, the sender's address per
the RFC's per-datagram source semantics; framing-once — the the RFC's per-datagram source semantics; framing-once, so the
length prefix is the wire layer's). `send` takes payload only. length prefix is the wire layer's). `send` takes payload only.
Egress failures map to `EgressError` (bind failure → `General`; Egress failures map to `EgressError` (bind failure → `General`;
resolve failure → `Unreachable`). resolve failure → `Unreachable`).
@@ -56,14 +56,14 @@ Deliverables:
requested target's port honored when non-zero (FTP active mode), requested target's port honored when non-zero (FTP active mode),
port 0 → ephemeral; `local_addr()` is reply#1's `bind_addr`; the port 0 → ephemeral; `local_addr()` is reply#1's `bind_addr`; the
accept future owns the listener end-to-end (the quiet wait's accept future owns the listener end-to-end (the quiet wait's
drop IS the client-gone teardown — the listener lives inside the drop IS the client-gone teardown, and the listener lives inside the
future, never in a separate binding); `accept()`'s peer is future, never in a separate binding); `accept()`'s peer is
reply#2's `BindTargetHandle.peer` (byte-exact). Factory failures reply#2's `BindTargetHandle.peer` (byte-exact). Factory failures
→ `BindAcceptFactoryError` (bind refused/held port → `Refused` → `BindAcceptFactoryError` (bind refused/held port → `Refused`
or `Shortage` per the io error; accept-stage failures → or `Shortage` per the io error; accept-stage failures →
`BindAcceptError`). Address policy is the caller's: the `BindAcceptError`). Address policy is the caller's: the
constructor takes the bind host (loopback default is the POC's constructor takes the bind host (loopback default is the POC's
posture — a wildcard bind is the caller's explicit choice). posture; a wildcard bind is the caller's explicit choice).
- Module doc: names the ADRs (008/009), the never-binds posture - Module doc: names the ADRs (008/009), the never-binds posture
(the providers ARE the explicit bind decisions), and the seam (the providers ARE the explicit bind decisions), and the seam
contracts each constructor must honor (readiness, ownership, contracts each constructor must honor (readiness, ownership,
@@ -72,7 +72,7 @@ Deliverables:
integration file): real sockets (dev-deps already carry full integration file): real sockets (dev-deps already carry full
tokio). Echo TCP server for `tcp_dial` round-trips; a real UDP tokio). Echo TCP server for `tcp_dial` round-trips; a real UDP
echo for the egress (datagram round-trip through the producer echo for the egress (datagram round-trip through the producer
stage — the POC #2 shape); a real BIND accept (dial the listener, stage, the POC #2 shape); a real BIND accept (dial the listener,
reply#1/reply#2 carry the real addresses); the readiness contract reply#1/reply#2 carry the real addresses); the readiness contract
re-pinned with the real socket egress (park-then-datagram, no re-pinned with the real socket egress (park-then-datagram, no
spin); refusal mappings (connect-refused → 0x05 through the spin); refusal mappings (connect-refused → 0x05 through the
@@ -84,15 +84,15 @@ Deliverables:
- [x] `cargo clippy --all-targets -- -D warnings` clean; `cargo fmt - [x] `cargo clippy --all-targets -- -D warnings` clean; `cargo fmt
--check` clean --check` clean
- [x] Default wasm checks stay green (`cargo check`/`clippy --target - [x] Default wasm checks stay green (`cargo check`/`clippy --target
wasm32-unknown-unknown -- -D warnings`) — `local` code is wasm32-unknown-unknown -- -D warnings`), meaning `local` code is
cfg-gated out of the default build cfg-gated out of the default build
- [x] Default dependency graph has no `tokio/net`/`mio`/`socket2` - [x] Default dependency graph has no `tokio/net`/`mio`/`socket2`
(`cargo tree` spot-check); `local = ["tokio/net"]` wired (`cargo tree` spot-check); `local = ["tokio/net"]` wired
- [x] `tcp_dial`/`udp_egress`/`bind_accept` land with the exact - [x] `tcp_dial`/`udp_egress`/`bind_accept` land with the exact
producer.md §Wiring names and the landed seam types producer.md §Wiring names and the landed seam types
- [x] The egress readiness contract honored (real async park; no hot - [x] The egress readiness contract honored (real async park; no hot
`Ok(None)`) — pinned by test `Ok(None)`), pinned by test
- [x] The accept future owns its listener end-to-end — pinned by a - [x] The accept future owns its listener end-to-end, pinned by a
drop-witness test (client-gone → listener closed) drop-witness test (client-gone → listener closed)
- [x] Refusal mappings table-tested against the landed error types' - [x] Refusal mappings table-tested against the landed error types'
`to_reply_error()` `to_reply_error()`
@@ -104,7 +104,7 @@ Deliverables:
seams), 007 (BIND variant 1), 012 (resolution at the egress) seams), 007 (BIND variant 1), 012 (resolution at the egress)
- docs/architecture/producer.md §Wiring, §The Three Seams - docs/architecture/producer.md §Wiring, §The Three Seams
- POC reference: `/workspace/alksocks-udp-poc/src/egress.rs` - POC reference: `/workspace/alksocks-udp-poc/src/egress.rs`
(`LocalEgress` — the readiness shape), `/workspace/alksocks-bind- (`LocalEgress`, the readiness shape), `/workspace/alksocks-bind-
poc/src/accept.rs` (`local_bind_accept`) poc/src/accept.rs` (`local_bind_accept`)
- Landed seams: `src/egress.rs` (the contracts' normative doc - Landed seams: `src/egress.rs` (the contracts' normative doc
comments) comments)
@@ -114,7 +114,7 @@ Deliverables:
> Filled by the implementation agent (2026-09-25). > Filled by the implementation agent (2026-09-25).
- `Cargo.toml`: `local = ["tokio/net"]` (the feature's own socket - `Cargo.toml`: `local = ["tokio/net"]` (the feature's own socket
providers; no `socket2` — the POCs ran tokio-only and nothing here providers; no `socket2`, because the POCs ran tokio-only and nothing here
needs it). tokio stays `default-features = false` with the needs it). tokio stays `default-features = false` with the
wasm-clean subset; the `net` feature rides `local` alone wasm-clean subset; the `net` feature rides `local` alone
(ADR-009). `cargo tree` spot-checks: the default graph (ADR-009). `cargo tree` spot-checks: the default graph
@@ -122,13 +122,13 @@ Deliverables:
`tokio/net`/`mio`/`socket2` (inverting `-i mio` prints nothing); `tokio/net`/`mio`/`socket2` (inverting `-i mio` prints nothing);
with `--features local --target all` the `net` feature appears with `--features local --target all` the `net` feature appears
rooted at `alksocks feature "local"`, dragging `mio` + `socket2` rooted at `alksocks feature "local"`, dragging `mio` + `socket2`
in through tokio (the dev-dep `full` also appears in dev builds — in through tokio (the dev-dep `full` also appears in dev builds,
pre-existing, test-only, not the feature's doing). pre-existing and test-only, not the feature's doing).
- `src/local/mod.rs` (feature `local`, one module file + a - `src/local/mod.rs` (feature `local`, one module file + a
`tests` submodule per convention 15): `tests` submodule per convention 15):
- `tcp_dial()` → `DialFn`: `TcpStream::connect` per CONNECT - `tcp_dial()` → `DialFn`: `TcpStream::connect` per CONNECT
target; domain targets resolve here (the ssh `-D` semantic — target; domain targets resolve here (the ssh `-D` semantic,
ADR-012), iterating the resolved address set (the `-D` with ADR-012 in play), iterating the resolved address set (the `-D`
connect-multi-address posture: first success wins, the last connect-multi-address posture: first success wins, the last
failure is the reported error); `into_split` halves boxed as failure is the reported error); `into_split` halves boxed as
`Send + Sync + Unpin` (compiles, pinned by the round-trip test). `Send + Sync + Unpin` (compiles, pinned by the round-trip test).
@@ -138,16 +138,16 @@ Deliverables:
resolution failure → `HostUnreachable`, everything else → resolution failure → `HostUnreachable`, everything else →
`General`; `NotAllowed` never synthesized. `General`; `NotAllowed` never synthesized.
- `udp_egress()` → `EgressFactory`: one real `UdpSocket` per - `udp_egress()` → `EgressFactory`: one real `UdpSocket` per
association (bind `127.0.0.1:0` — the explicit assembly-layer association (bind `127.0.0.1:0`, the explicit assembly-layer
bind decision, `LocalEgress::bind`); per-datagram bind decision, `LocalEgress::bind`); per-datagram
`send_to`/`recv_from`, no flow table; domain destinations `send_to`/`recv_from`, no flow table; domain destinations
resolve at the egress (`resolve_target` — the POC #2 resolve at the egress (`resolve_target`, porting the POC #2
`LocalEgress` helper ported). The readiness contract is the `LocalEgress` helper ported). The readiness contract is the
load-bearing shape: `next_reply` parks on `sock.readable().await` load-bearing shape: `next_reply` parks on `sock.readable().await`
(a real async park on receive readiness — `writable()` is always (a real async park on receive readiness, since `writable()` is always
ready for UDP and would spin), then `try_recv_from` ready for UDP and would spin), then `try_recv_from`
(`WouldBlock` → the spurious-wakeup `Ok(None)`); replies carry (`WouldBlock` → the spurious-wakeup `Ok(None)`); replies carry
*inner* bytes (`TargetAddr::to_be_bytes() + data` — the sender's *inner* bytes (`TargetAddr::to_be_bytes() + data`, the sender's
address per the RFC's per-datagram source semantics; address per the RFC's per-datagram source semantics;
framing-once); `send` takes payload only. Bind failure → framing-once); `send` takes payload only. Bind failure →
`General`; resolve failure → `Unreachable`. `General`; resolve failure → `Unreachable`.
@@ -155,7 +155,7 @@ Deliverables:
`TcpListener` per BIND session (ADR-007 variant 1); the `TcpListener` per BIND session (ADR-007 variant 1); the
requested port honored when non-zero, port 0 → ephemeral; requested port honored when non-zero, port 0 → ephemeral;
`local_addr()` is reply#1's `bind_addr`; the accept future owns `local_addr()` is reply#1's `bind_addr`; the accept future owns
the listener end-to-end (the listener lives inside the future — the listener end-to-end (the listener lives inside the future, so
the quiet wait's drop IS the client-gone teardown); the quiet wait's drop IS the client-gone teardown);
`accept()`'s peer is reply#2's `BindTargetHandle.peer` `accept()`'s peer is reply#2's `BindTargetHandle.peer`
(byte-exact). Factory failures → `BindAcceptFactoryError` (byte-exact). Factory failures → `BindAcceptFactoryError`
@@ -163,7 +163,7 @@ Deliverables:
`AddrNotAvailable` → `Refused`, else `General`); `AddrNotAvailable` → `Refused`, else `General`);
accept-stage failures → `BindAcceptError` (`General`). Address accept-stage failures → `BindAcceptError` (`General`). Address
policy is the caller's: the constructor takes the bind host policy is the caller's: the constructor takes the bind host
(`IpAddr`-convertible; loopback is the POC's posture — the (`IpAddr`-convertible; loopback is the POC's posture, and the
wildcard bind is the caller's explicit `0.0.0.0`). wildcard bind is the caller's explicit `0.0.0.0`).
- Module doc names ADR-008/009, the never-binds posture (the - Module doc names ADR-008/009, the never-binds posture (the
providers ARE the explicit bind decisions), and the four seam providers ARE the explicit bind decisions), and the four seam
@@ -173,14 +173,14 @@ Deliverables:
`tcp_dial`/`udp_egress`/`bind_accept`/`LocalEgress` re-exports; `tcp_dial`/`udp_egress`/`bind_accept`/`LocalEgress` re-exports;
the crate doc's "not yet present" paragraph updated (the composed the crate doc's "not yet present" paragraph updated (the composed
providers remain pending). providers remain pending).
- Tests: 12 unit tests (`src/local/tests.rs` — the TCP echo - Tests: 12 unit tests (`src/local/tests.rs`: the TCP echo
round-trip incl. domain resolution, connect-refused/unresolvable round-trip incl. domain resolution, connect-refused/unresolvable
mappings, the UDP echo round-trips (inner bytes + sender address, mappings, the UDP echo round-trips (inner bytes + sender address,
in-order, empty payload), the park-then-datagram readiness probe, in-order, empty payload), the park-then-datagram readiness probe,
the BIND accept (real reply#1/reply#2, requested-port honored, the BIND accept (real reply#1/reply#2, requested-port honored,
held-port refusal, the drop-closes-the-listener witness)) + 6 held-port refusal, the drop-closes-the-listener witness)) + 6
integration tests (`tests/local-providers.rs`, feature-gated with integration tests (`tests/local-providers.rs`, feature-gated with
`#![cfg(feature = "local")]` — CONNECT through a real echo target, `#![cfg(feature = "local")]`: CONNECT through a real echo target,
connect-refused → 0x05 through the producer's reply, ASSOCIATE connect-refused → 0x05 through the producer's reply, ASSOCIATE
through the producer's datagram stage with the real egress, the through the producer's datagram stage with the real egress, the
readiness contract re-pinned through the stage (park-then-datagram, readiness contract re-pinned through the stage (park-then-datagram,
@@ -202,17 +202,17 @@ Deliverables:
> Filled on completion (2026-09-25). > Filled on completion (2026-09-25).
Implemented the `local` feature's socket-backed seam providers Implemented the `local` feature's socket-backed seam providers
(ADR-008's local variants, ADR-009's feature gate) — the three seams' (ADR-008's local variants, ADR-009's feature gate), the three seams'
faithful-RFC assembly-layer constructors over the landed seam types faithful-RFC assembly-layer constructors over the landed seam types
(`src/egress.rs`): `tcp_dial()` (real `TcpStream` per CONNECT, domain (`src/egress.rs`): `tcp_dial()` (real `TcpStream` per CONNECT, domain
resolution at the provider, faithful `SocksDialError` mapping), resolution at the provider, faithful `SocksDialError` mapping),
`udp_egress()` (one real `UdpSocket` per association bound `udp_egress()` (one real `UdpSocket` per association bound
`127.0.0.1:0`, per-datagram send/recv, the POC #2 `LocalEgress` `127.0.0.1:0`, per-datagram send/recv, the POC #2 `LocalEgress`
readiness shape — park on `readable()`, `try_recv_from`, inner bytes readiness shape: park on `readable()`, `try_recv_from`, inner bytes
`to_be_bytes() + data`, framing-once), and `bind_accept(bind_host)` `to_be_bytes() + data`, framing-once), and `bind_accept(bind_host)`
(a real `TcpListener` per BIND session, requested port honored, the (a real `TcpListener` per BIND session, requested port honored, the
accept future owning the listener end-to-end, two-stage failure accept future owning the listener end-to-end, two-stage failure
mapping). The protocol layer never sees them — feature-gated mapping). The protocol layer never sees them; these are feature-gated
(`local = ["tokio/net"]`), the default graph stays (`local = ["tokio/net"]`), the default graph stays
net/mio/socket2-free (`cargo tree` spot-checked both ways), and the net/mio/socket2-free (`cargo tree` spot-checked both ways), and the
wasm checks stay green. 18 new tests (12 unit + 6 integration) pin: wasm checks stay green. 18 new tests (12 unit + 6 integration) pin:
+50 -50
View File
@@ -1,6 +1,6 @@
--- ---
id: review-assembly-layer id: review-assembly-layer
name: Review — the assembly layer (the batch-2 gate) name: Review (the assembly layer, the batch-2 gate)
status: completed status: completed
depends_on: [implementation/assembly-integration-suite] depends_on: [implementation/assembly-integration-suite]
scope: narrow scope: narrow
@@ -20,7 +20,7 @@ against the Phase 1 spec set; the remaining work is release polish
Review against the architecture, in this order: Review against the architecture, in this order:
1. **The never-binds posture** (the crate's defining requirement): 1. **The never-binds posture** (the crate's defining requirement):
the protocol layer still binds nothing — every kernel bind lives the protocol layer still binds nothing; every kernel bind lives
in `local`-gated code, is an explicit constructor decision, and in `local`-gated code, is an explicit constructor decision, and
the default crate's dependency graph carries no `tokio/net` the default crate's dependency graph carries no `tokio/net`
(`cargo tree` spot-check). The doors' binds are the doors' own (`cargo tree` spot-check). The doors' binds are the doors' own
@@ -38,34 +38,34 @@ Review against the architecture, in this order:
never crosses the channel as a SOCKS password; the RFC UDP-header never crosses the channel as a SOCKS password; the RFC UDP-header
codec appears only at the door boundary; FRAG ≠ 0 refused. codec appears only at the door boundary; FRAG ≠ 0 refused.
4. **The graduation's shape** (ADR-007 variant 2): the composed 4. **The graduation's shape** (ADR-007 variant 2): the composed
accept advertises the real `bound` and the real `peer` — no accept advertises the real `bound` and the real `peer`, with no
expected-addr stand-in; the POC's superseded workaround is not expected-addr stand-in; the POC's superseded workaround is not
resurrected. resurrected.
5. **Convention compliance** (AGENTS.md): no `unwrap`/`expect`/ 5. **Convention compliance** (AGENTS.md): no `unwrap`/`expect`/
panics outside tests; `thiserror` surfaces; no comments in code panics outside tests; `thiserror` surfaces; no comments in code
except doc comments + pinned non-obvious-correctness notes; except doc comments + pinned non-obvious-correctness notes;
naming; module structure (feature-gated backend modules never naming; module structure (feature-gated backend modules never
imported from the protocol/adapter/client modules — convention imported from the protocol/adapter/client modules, per convention
15's last line); producer/consumer vocabulary. 15's last line); producer/consumer vocabulary.
6. **Feature-gate hygiene** (ADR-009): `local` brings only 6. **Feature-gate hygiene** (ADR-009): `local` brings only
`tokio/net` (+ `socket2` if justified); `composed` brings only `tokio/net` (+ `socket2` if justified); `composed` brings only
alktunnels; the default graph unchanged; the wasm checks green alktunnels; the default graph unchanged; the wasm checks green
for default + composed; `local` on wasm fails to build (the for default + composed; `local` on wasm fails to build (the
alktty posture — verify the failure is the expected one). alktty posture; verify the failure is the expected one).
7. **POC-fidelity spot-check**: for two or three of the trickiest 7. **POC-fidelity spot-check**: for two or three of the trickiest
assembly behaviors (the composed egress's LRU eviction + closed- assembly behaviors (the composed egress's LRU eviction + closed-
queue posture, the cred-bearing door's re-origination, the queue posture, the cred-bearing door's re-origination, the
bind-first correlation), diff the crate's behavior against the bind-first correlation), diff the crate's behavior against the
POC crates' findings — the POCs are the empirical record; drift POC crates' findings; the POCs are the empirical record, so drift
is a finding (except where an ADR supersedes the POC's shape — is a finding (except where an ADR supersedes the POC's shape, of
the bind-first graduation is the known one). which the bind-first graduation is the known one).
8. **Docs sync**: the specs match what landed (the integration 8. **Docs sync**: the specs match what landed (the integration
task's reconciliation verified; any residue is a finding). task's reconciliation verified; any residue is a finding).
## Summary ## Summary
Review performed 2026-09-26 against the eight-criteria order; the gate Review performed 2026-09-26 against the eight-criteria order; the gate
opens (the crate is feature-complete against the Phase 1 spec set — opens (the crate is feature-complete against the Phase 1 spec set;
release polish remains). One major correctness finding was corrected release polish remains). One major correctness finding was corrected
in the review change itself (R-1: the UDP door's in-band bridge lost in the review change itself (R-1: the UDP door's in-band bridge lost
the reader-state contract), plus a convention sweep residue (R-2: the reader-state contract), plus a convention sweep residue (R-2:
@@ -74,113 +74,113 @@ two doc residues (R-3, R-4). No seam-contract, never-binds,
graduation-shape, feature-gate, or POC-fidelity drift was found graduation-shape, feature-gate, or POC-fidelity drift was found
beyond these. beyond these.
**1. The never-binds posture — clean.** The protocol layer **1. The never-binds posture: clean.** The protocol layer
(`producer.rs`/`consumer.rs`/`wire.rs`/`params.rs`/`egress.rs`/ (`producer.rs`/`consumer.rs`/`wire.rs`/`params.rs`/`egress.rs`/
`error.rs`/`rfc1928/`) names no kernel-socket type (`tokio::net` `error.rs`/`rfc1928/`) names no kernel-socket type (`tokio::net`
appears only in doc prose; zero code references — swept). The default appears only in doc prose; zero code references, swept). The default
dependency graph carries no `tokio/net`/`mio`/`socket2` dependency graph carries no `tokio/net`/`mio`/`socket2`
(`cargo tree -e normal -f "{p} [{f}]"`: tokio rides the wasm-clean (`cargo tree -e normal -f "{p} [{f}]"`: tokio rides the wasm-clean
subset `bytes,io-util,macros,rt,sync,time`; `net` appears only under subset `bytes,io-util,macros,rt,sync,time`; `net` appears only under
`--features local`, which also brings `mio`+`socket2`; `composed` `--features local`, which also brings `mio`+`socket2`; `composed`
adds alktunnels 0.2.0 only and its tokio stays on the clean subset). adds alktunnels 0.2.0 only and its tokio stays on the clean subset).
Every kernel bind is an explicit constructor decision in `local`-gated Every kernel bind is an explicit constructor decision in `local`-gated
code (`local/mod.rs` — the dial's connect, `LocalEgress::bind`'s code (`local/mod.rs` with the dial's connect, `LocalEgress::bind`'s
loopback `127.0.0.1:0` (a wildcard bind is the caller's variant, doc loopback `127.0.0.1:0` (a wildcard bind is the caller's variant, doc
pinned), `local_bind_accept`'s explicit `bind_host` param, the doors' pinned), `local_bind_accept`'s explicit `bind_host` param, the doors'
`TcpListener::bind`/`UdpSocket::bind` behind builder `.bind()`) — `TcpListener::bind`/`UdpSocket::bind` behind builder `.bind()`), and
the doors' binds are the doors' own (assembly-layer, ADR-009). the doors' binds are the doors' own (assembly-layer, ADR-009).
**2. Seam-contract compliance (ADR-004) — clean.** *Readiness*: the **2. Seam-contract compliance (ADR-004): clean.** *Readiness*: the
local egress parks on `sock.readable().await` then `try_recv_from` local egress parks on `sock.readable().await` then `try_recv_from`
(`WouldBlock` → the spurious `Ok(None)`, never hot); the composed (`WouldBlock` → the spurious `Ok(None)`, never hot); the composed
egress parks on the shared queue's `recv().await`, and its egress parks on the shared queue's `recv().await`, and its
closed-queue condition is a **count-based design** (`live_relays` + closed-queue condition is a **count-based design** (`live_relays` +
`ever_opened`) — a real improvement over the POC (the POC held its `ever_opened`), which is a real improvement over the POC (the POC held its
own `inbound_tx` clone, so its queue-`None` arm was unreachable; the own `inbound_tx` clone, so its queue-`None` arm was unreachable; the
landed egress actually detects every-relay-ended and returns `Err`, landed egress actually detects every-relay-ended and returns `Err`,
pinned by `closed_queue_yields_err_not_a_hot_none`). *Ownership*: pinned by `closed_queue_yields_err_not_a_hot_none`). *Ownership*:
rides the handle structurally everywhere — the local providers box rides the handle structurally everywhere: the local providers box
their socket halves into the payloads; the composed accept wraps the their socket halves into the payloads; the composed accept wraps the
`Send + Unpin`-only alktunnels halves in `SyncHalves`/`OwnedWrite` `Send + Unpin`-only alktunnels halves in `SyncHalves`/`OwnedWrite`
(`Mutex<T: Send>` is `Sync`, no unsafe) with the `TunnelListener` (`Mutex<T: Send>` is `Sync`, no unsafe) with the `TunnelListener`
riding INSIDE the write payload (`ForwardedOwner` — the riding INSIDE the write payload (`ForwardedOwner`; the
disjoint-capture trap is doc-pinned and the whole-struct rebinding is disjoint-capture trap is doc-pinned and the whole-struct rebinding is
in place); the egress keeps the session in the table entry (the POC's in place); the egress keeps the session in the table entry (the POC's
insert-time-bug lesson, doc-pinned). No `_` bindings of owning values insert-time-bug lesson, doc-pinned). No `_` bindings of owning values
(swept). *The accept future owns its resources end-to-end* in both (swept). *The accept future owns its resources end-to-end* in both
variants (the listener lives inside the future; the composed future variants (the listener lives inside the future; the composed future
owns the listener + the oneshot receiver — the client-gone select! owns the listener + the oneshot receiver; the client-gone select!
drop tears both down). *Framing-once*: the local egress returns drop tears both down). *Framing-once*: the local egress returns
`addr + data` inner bytes; the composed egress returns inner bytes `addr + data` inner bytes; the composed egress returns inner bytes
with its own oversize guard mirroring the wire bound; the tunnel-side with its own oversize guard mirroring the wire bound; the tunnel-side
framing is the tunnel codec's (`frame_datagram`), never the RFC framing is the tunnel codec's (`frame_datagram`), never the RFC
frame's. frame's.
**3. Door fidelity (ADR-005/ADR-003) — clean, one finding corrected **3. Door fidelity (ADR-005/ADR-003): clean, with one finding corrected
(R-1).** The NoAuth door is a literal raw pipe (R-1).** The NoAuth door is a literal raw pipe
(`copy_bidirectional`, zero translation — pinned by (`copy_bidirectional`, zero translation, pinned by
`door-tcp.rs`'s raw-byte probes). The cred-bearing door terminates `door-tcp.rs`'s raw-byte probes). The cred-bearing door terminates
RFC 1929 locally through the forked `accept_password_auth` surface, RFC 1929 locally through the forked `accept_password_auth` surface,
maps through the static table (the check IS the lookup — one table, maps through the static table (the check IS the lookup, i.e. one table,
one truth), and re-originates in-band via the token sessions; the one truth), and re-originates in-band via the token sessions; the
never-pipes-raw-post-auth property is pinned by the re-origination never-pipes-raw-post-auth property is pinned by the re-origination
probe (the vanilla client's command bytes are consumed door-side; probe (the vanilla client's command bytes are consumed door-side;
the in-band conversation is fresh). The token is the mapped the in-band conversation is fresh). The token is the mapped
identity's id riding the open-op `auth_token` payload (CF-005 identity's id riding the open-op `auth_token` payload (CF-005
precedence (1)) — the password never crosses the channel (pinned); precedence (1), and the password never crosses the channel (pinned);
the identity never reaches the vanilla client. The RFC UDP-header the identity never reaches the vanilla client. The RFC UDP-header
codec (`parse_udp_request`/`new_udp_header`) appears only at the codec (`parse_udp_request`/`new_udp_header`) appears only at the
door's boundary (the in-band wire stays `{addr, data}` frames — door's boundary (the in-band wire stays `{addr, data}` frames,
pinned end to end through a real echo target); FRAG ≠ 0 is refused by pinned end to end through a real echo target); FRAG ≠ 0 is refused by
dropping the datagram, the association stays healthy (pinned). The dropping the datagram, the association stays healthy (pinned). The
TCP door's ASSOCIATE arm refuses RFC-faithfully before any session TCP door's ASSOCIATE arm refuses RFC-faithfully before any session
opens (no phantom data plane). R-1 is the UDP door's in-band decode opens (no phantom data plane). R-1 is the UDP door's in-band decode
state — see the Findings. state (see the Findings).
**4. The graduation's shape (ADR-007 variant 2) — clean.** The **4. The graduation's shape (ADR-007 variant 2): clean.** The
composed accept advertises the real OS-chosen `bound` (the open-op composed accept advertises the real OS-chosen `bound` (the open-op
reply's `bound` field via the reply projection; a missing reply's `bound` field via the reply projection; a missing
`bound`/`listen` FAILS the factory — `Shortage` with an explicit `bound`/`listen` FAILS the factory with `Shortage` and an explicit
no-stand-in message, never an expected-addr stand-in) and the real no-stand-in message, never an expected-addr stand-in) and the real
forwarded-op `peer` (a `Path` peer maps to the RFC sentinel — the forwarded-op `peer` (a `Path` peer maps to the RFC sentinel, the
documented addressless case, not a stand-in). The POC's superseded documented addressless case, not a stand-in). The POC's superseded
workaround (reply#1-first + carried `bind_addr` + sentinel peer) is workaround (reply#1-first + carried `bind_addr` + sentinel peer) is
not resurrected — `tests/composed-accept.rs` pins the real addresses not resurrected; `tests/composed-accept.rs` pins the real addresses
end to end (`reply#1 advertises the real OS-chosen bound`, `reply#2 end to end (`reply#1 advertises the real OS-chosen bound`, `reply#2
carries the forwarded op's real peer`), and the establishment bound carries the forwarded op's real peer`), and the establishment bound
covers only the open-op round trip (the listener open is the covers only the open-op round trip (the listener open is the
factory's; the accept wait is the teardown-governed quiet-wait phase). factory's; the accept wait is the teardown-governed quiet-wait phase).
**5. Convention compliance — one residue corrected (R-2).** After **5. Convention compliance: one residue corrected (R-2).** After
R-2, no `unwrap`/`expect`/`panic!`/`unreachable!` in non-test library R-2, no `unwrap`/`expect`/`panic!`/`unreachable!` in non-test library
code (the seven batch-2 additions were the builder-required-field code (the seven batch-2 additions were the builder-required-field
`expect`s and two internal-invariant `expect`s — same class as `expect`s and two internal-invariant `expect`s, the same class as
batch-1's R-2; all now typed). `thiserror` surfaces throughout (the batch-1's R-2; all now typed). `thiserror` surfaces throughout (the
new builder errors included); naming/module structure hold — the new builder errors included); naming/module structure hold; the
feature-gated backend modules (`local/`, `composed/`, `door`) are feature-gated backend modules (`local/`, `composed/`, `door`) are
never imported from the protocol modules (swept: zero never imported from the protocol modules (swept: zero
`crate::local`/`crate::door`/`crate::composed` references in the `crate::local`/`crate::door`/`crate::composed` references in the
protocol layer); producer/consumer vocabulary holds (RFC-facing protocol layer); producer/consumer vocabulary holds (RFC-facing
"server"/"client" only inside `src/rfc1928/`). "server"/"client" only inside `src/rfc1928/`).
**6. Feature-gate hygiene (ADR-009) — green, all four targets.** **6. Feature-gate hygiene (ADR-009): green on all four targets.**
`cargo test` (default) green; `cargo test --all-features` green; both `cargo test` (default) green; `cargo test --all-features` green; both
wasm checks clean for default AND `--features composed` (the wasm checks clean for default AND `--features composed` (the
composed wasm gate verified live); `local` on wasm fails to build composed wasm gate verified live); `local` on wasm fails to build
with exactly the expected shape — `mio` cannot compile on with exactly the expected shape: `mio` cannot compile on
`wasm32-unknown-unknown` (the alktty posture's build-error `wasm32-unknown-unknown` (the alktty posture's build-error
guarantee, verified). `local` brings only `tokio/net` (+ `mio`/ guarantee, verified). `local` brings only `tokio/net` (+ `mio`/
`socket2` transitively); `composed` brings only alktunnels; the `socket2` transitively); `composed` brings only alktunnels; the
default graph is unchanged from batch 1 (spot-checked per feature). default graph is unchanged from batch 1 (spot-checked per feature).
**7. POC-fidelity spot-check — clean, with the R-1 exception and one **7. POC-fidelity spot-check: clean, with the R-1 exception and one
documented improvement.** documented improvement.**
- *The composed egress's LRU + closed-queue posture*: the lazy - *The composed egress's LRU + closed-queue posture*: the lazy
destination→tunnel table, the LRU eviction-before-insert loop, the destination→tunnel table, the LRU eviction-before-insert loop, the
touch-on-access ordering, the session-inside-the-entry placement, touch-on-access ordering, the session-inside-the-entry placement,
and the relay task's shape are all line-faithful to POC #2's and the relay task's shape are all line-faithful to POC #2's
`ComposedEgress` (`/workspace/alksocks-udp-poc/src/egress.rs`). `ComposedEgress` (`/workspace/alksocks-udp-poc/src/egress.rs`).
The one deliberate divergence is the closed-queue detection — the The one deliberate divergence is the closed-queue detection: the
POC's `recv().await`-`None` arm was structurally unreachable (it POC's `recv().await`-`None` arm was structurally unreachable (it
held its own `Sender`); the crate replaces it with the held its own `Sender`); the crate replaces it with the
`live_relays`/`ever_opened` count so the association actually ends `live_relays`/`ever_opened` count so the association actually ends
@@ -191,27 +191,27 @@ documented improvement.**
NoAuth variant; the cred-bearing extension is the ADR-005 design, NoAuth variant; the cred-bearing extension is the ADR-005 design,
validated by the integration suite's vanilla-client round trips). validated by the integration suite's vanilla-client round trips).
- *The bind-first correlation*: the token→oneshot registry shape - *The bind-first correlation*: the token→oneshot registry shape
with the forwarded handler owning the data-plane pump (R-02 held — with the forwarded handler owning the data-plane pump (R-02 held,
the handler's inline `pump_bidi` await tracks the plane) matches as the handler's inline `pump_bidi` await tracks the plane) matches
the alktunnels 0.2.0 forwarded-op surface; the three-party the alktunnels 0.2.0 forwarded-op surface; the three-party
half-close deadlock note (the bridge completes on EITHER half-close deadlock note (the bridge completes on EITHER
direction's EOF) is the pinned non-obvious-correctness comment direction's EOF) is the pinned non-obvious-correctness comment
done right. done right.
**8. Docs sync — two residues fixed (R-3, R-4).** The integration **8. Docs sync: two residues fixed (R-3, R-4).** The integration
task's reconciliation was verified: overview.md (module map, feature task's reconciliation was verified: overview.md (module map, feature
gates, dependencies), consumer.md (the front doors, the TokenClient gates, dependencies), consumer.md (the front doors, the TokenClient
door-only contract, the BIND take-guard), producer.md §Wiring, and door-only contract, the BIND take-guard), producer.md §Wiring, and
README.md Current State all match the tree. Residues fixed: README.md Current State all match the tree. Residues fixed:
producer.md's local-backend section described the landed shape as a producer.md's local-backend section described the landed shape as a
channel pipe (`copy_bidirectional` onto a `BiStream`) — the landed channel pipe (`copy_bidirectional` onto a `BiStream`), but the landed
`ProducerDoor` serves the RFC conversation on the accepted socket `ProducerDoor` serves the RFC conversation on the accepted socket
directly via `serve_socks5_over` (no channel in the path; rewritten, directly via `serve_socks5_over` (no channel in the path; rewritten,
including the `bind_policy` gate note the docs omitted); including the `bind_policy` gate note the docs omitted);
`associate_with_token`'s doc claimed "the TCP door uses this" — the `associate_with_token`'s doc claimed "the TCP door uses this", but the
landed TCP door refuses ASSOCIATE before any session (reworded); landed TCP door refuses ASSOCIATE before any session (reworded);
`SessionOwner`'s docs claimed it is "shared with the composed `SessionOwner`'s docs claimed it is "shared with the composed
accept" — the accept uses its own `SyncHalves`/`OwnedWrite` pair accept", yet the accept uses its own `SyncHalves`/`OwnedWrite` pair
(reworded). No other spec drift. (reworded). No other spec drift.
## Acceptance Criteria ## Acceptance Criteria
@@ -232,22 +232,22 @@ accept" — the accept uses its own `SyncHalves`/`OwnedWrite` pair
- **R-1 (major → fixed in the review change)**: the UDP front door's - **R-1 (major → fixed in the review change)**: the UDP front door's
in-band decode (`recv_inband`, `src/door.rs`) created a fresh in-band decode (`recv_inband`, `src/door.rs`) created a fresh
`DatagramReader` per call and returned only the first frame of `DatagramReader` per call and returned only the first frame of
each feed — a datagram split across channel chunks would have each feed, so a datagram split across channel chunks would have
restarted the reassembly from scratch per read, and datagrams restarted the reassembly from scratch per read, and datagrams
batched beyond the first in one chunk were **silently dropped**. batched beyond the first in one chunk were **silently dropped**.
This is exactly POC #2's first-cut bug (ADR-003: "reader state This is exactly POC #2's first-cut bug (ADR-003: "reader state
persists across calls"; `AssociateSession::recv_datagram` owns the persists across calls"; `AssociateSession::recv_datagram` owns the
pinned shape) — the door bridge did not carry it, and its own doc pinned shape), yet the door bridge did not carry it, and its own doc
comment claimed persistence. Unexercised by the suite (every comment claimed persistence. Unexercised by the suite (every
landed test drives one frame per chunk; the failure needs two landed test drives one frame per chunk; the failure needs two
frames coalescing into one read — a burst shape real echo targets frames coalescing into one read, a burst shape real echo targets
produce under load). Fix: an owning `InbandBridge` (persistent produce under load). Fix: an owning `InbandBridge` (persistent
`DatagramReader` + batched-datagram pending queue, the `DatagramReader` + batched-datagram pending queue, the
`AssociateSession` shape) threaded through the channel→socket `AssociateSession` shape) threaded through the channel→socket
pump, plus the fail-loud mid-frame EOF (never a silent partial pump, plus the fail-loud mid-frame EOF (never a silent partial
datagram). Pinned by three unit tests on the bridge (batched datagram). Pinned by three unit tests on the bridge (batched
frames queue-not-drop; partial-frame state persists across calls frames queue-not-drop; partial-frame state persists across calls,
— the test fails against a per-call reader; EOF mid-frame errors so the test fails against a per-call reader; EOF mid-frame errors
loud) and an end-to-end door-udp test with a doubled-echo egress loud) and an end-to-end door-udp test with a doubled-echo egress
(`batched_in_band_replies_both_reach_the_vanilla_client`). (`batched_in_band_replies_both_reach_the_vanilla_client`).
- **R-2 (convention, fixed in the review change)**: seven non-test - **R-2 (convention, fixed in the review change)**: seven non-test
@@ -265,13 +265,13 @@ accept" — the accept uses its own `SyncHalves`/`OwnedWrite` pair
diagnostic surfaced, `# Panics` → `# Errors`); diagnostic surfaced, `# Panics` → `# Errors`);
`TokenClient::wrap`'s call-connection-taken panic → `TokenClient::wrap`'s call-connection-taken panic →
`TokenClientError::CallConnectionTaken` (a client is one calling `TokenClientError::CallConnectionTaken` (a client is one calling
surface — the door-only contract). Test call sites updated surface, the door-only contract). Test call sites updated
(`.expect("the door builds")` etc. — test-side, allowed); the new (`.expect("the door builds")` etc., which is test-side and allowed); the new
error types are re-exported (`lib.rs`). error types are re-exported (`lib.rs`).
- **R-3 (doc residue, fixed)**: producer.md's producer-side - **R-3 (doc residue, fixed)**: producer.md's producer-side
local-backend section described the landed shape as a raw channel local-backend section described the landed shape as a raw channel
pipe (`copy_bidirectional` onto a `BiStream`, "the consumer door's pipe (`copy_bidirectional` onto a `BiStream`, "the consumer door's
code shape") — the landed `ProducerDoor` hands each accepted code shape") since the landed `ProducerDoor` hands each accepted
socket to `serve_socks5_over` (the RFC conversation runs on the socket to `serve_socks5_over` (the RFC conversation runs on the
socket; no channel pipe). Reconciled, including the socket; no channel pipe). Reconciled, including the
`bind_policy`-is-this-path's-BIND-gate note and the cred-bearing `bind_policy`-is-this-path's-BIND-gate note and the cred-bearing
@@ -284,7 +284,7 @@ accept" — the accept uses its own `SyncHalves`/`OwnedWrite` pair
- **R-5 (accepted, no fix demanded)**: the end-to-end - **R-5 (accepted, no fix demanded)**: the end-to-end
`batched_in_band_replies...` test does not deterministically `batched_in_band_replies...` test does not deterministically
exercise the batching (the two echo frames may ride separate exercise the batching (the two echo frames may ride separate
channel chunks — timing-dependent); the deterministic pins are the channel chunks, i.e. timing-dependent); the deterministic pins are the
bridge unit tests (which demonstrably fail against the per-call bridge unit tests (which demonstrably fail against the per-call
reader). The end-to-end test still guards the regression class reader). The end-to-end test still guards the regression class
through the real stack; the unit tests carry the determinism. through the real stack; the unit tests carry the determinism.
+34 -34
View File
@@ -1,6 +1,6 @@
--- ---
id: review-fix-round-002 id: review-fix-round-002
name: "Review — the review-002 fix round before batch 2" name: "Review: the review-002 fix round before batch 2"
status: completed status: completed
depends_on: [implementation/fix-bind-reply2-reentry, implementation/fix-producer-gate-policy, implementation/fix-rfc1928-posture, implementation/fix-hygiene-residue, implementation/fix-coverage-residue, implementation/fix-test-helper-consolidation, architecture/adr-014-idle-timeout-posture] depends_on: [implementation/fix-bind-reply2-reentry, implementation/fix-producer-gate-policy, implementation/fix-rfc1928-posture, implementation/fix-hygiene-residue, implementation/fix-coverage-residue, implementation/fix-test-helper-consolidation, architecture/adr-014-idle-timeout-posture]
scope: narrow scope: narrow
@@ -11,14 +11,14 @@ level: review
## Description ## Description
The closing checkpoint for the review-002 fix round — the gate The closing checkpoint for the review-002 fix round: the gate
between the fix tasks and batch 2 (the assembly layer: `local` between the fix tasks and batch 2 (the assembly layer: `local`
providers, front doors, `composed` providers). Batch 2 consumes the providers, front doors, `composed` providers). Batch 2 consumes the
session types, the gate entries, and the fork's posture, so a fix session types, the gate entries, and the fork's posture, so a fix
that silently changed a shape (the C-1 take-ownership, the C-3 gate that silently changed a shape (the C-1 take-ownership, the C-3 gate
entry, the N-4 vocabulary) would propagate into every door task. entry, the N-4 vocabulary) would propagate into every door task.
This is an inline review (it lives in `tasks/` and does not consume This is an inline review (it lives in `tasks/` and does not consume
the `docs/reviews/` numbering — the next general review doc is the `docs/reviews/` numbering, because the next general review doc is
`003-*`). `003-*`).
Verify the fix round against review 002's findings, in this order: Verify the fix round against review 002's findings, in this order:
@@ -41,8 +41,8 @@ Verify the fix round against review 002's findings, in this order:
bytes are untouched, and the fork's provenance notice is bytes are untouched, and the fork's provenance notice is
extended-not-replaced (convention 17). extended-not-replaced (convention 17).
5. **D-3 resolved**: the `ErrorDefinition` is declared (or the doc 5. **D-3 resolved**: the `ErrorDefinition` is declared (or the doc
honestly reworded with the reason recorded) — doc-vs-code drift honestly reworded with the reason recorded), so doc-vs-code drift
gone either way. is gone either way.
6. **N-8 coverage**: `cargo llvm-cov` re-run; the named lines 6. **N-8 coverage**: `cargo llvm-cov` re-run; the named lines
covered or recorded as accepted-uncovered with reasons; no new covered or recorded as accepted-uncovered with reasons; no new
load-bearing gaps introduced by the fix round itself. load-bearing gaps introduced by the fix round itself.
@@ -50,7 +50,7 @@ Verify the fix round against review 002's findings, in this order:
into both test crates; the deliberately-distinct shapes were not into both test crates; the deliberately-distinct shapes were not
collapsed; the constructor prologue is single-sourced. collapsed; the constructor prologue is single-sourced.
8. **C-2 decided**: ADR-014 exists, decides explicitly, and the 8. **C-2 decided**: ADR-014 exists, decides explicitly, and the
spec lines carry the posture — the batch-2 gate condition. spec lines carry the posture, which is the batch-2 gate condition.
9. **Cross-cutting**: the full verification sweep (test, 9. **Cross-cutting**: the full verification sweep (test,
all-features, clippy, fmt, doc, both wasm checks, all-features, clippy, fmt, doc, both wasm checks,
publish-dry-run); no convention regressions (no publish-dry-run); no convention regressions (no
@@ -66,7 +66,7 @@ when C-2's ADR is accepted and no major finding remains.
## Acceptance Criteria ## Acceptance Criteria
- [x] Each review-002 finding verified closed (or explicitly - [x] Each review-002 finding verified closed (or explicitly
re-dispositioned with a reason) — the per-finding checklist re-dispositioned with a reason) via the per-finding checklist
above above
- [x] `cargo llvm-cov` re-run and attributed (no new load-bearing - [x] `cargo llvm-cov` re-run and attributed (no new load-bearing
gaps) gaps)
@@ -74,14 +74,14 @@ when C-2's ADR is accepted and no major finding remains.
`cargo publish --dry-run --allow-dirty` `cargo publish --dry-run --allow-dirty`
- [x] Convention compliance re-swept (no unwrap/expect/panics - [x] Convention compliance re-swept (no unwrap/expect/panics
outside tests; comment posture; wasm-clean) outside tests; comment posture; wasm-clean)
- [x] ADR-014 accepted — the batch-2 gate condition holds - [x] ADR-014 accepted, the batch-2 gate condition
- [x] Findings recorded; the gate to batch 2 opens - [x] Findings recorded; the gate to batch 2 opens
## References ## References
- docs/reviews/002-protocol-core-implementation-review.md (the - docs/reviews/002-protocol-core-implementation-review.md (the
findings this round closes) findings this round closes)
- docs/architecture/decisions/014-*.md (the C-2 decision — the - docs/architecture/decisions/014-*.md (the C-2 decision, the
gate condition) gate condition)
- tasks/implementation/review-protocol-core.md (the inline-review - tasks/implementation/review-protocol-core.md (the inline-review
shape and findings format this follows) shape and findings format this follows)
@@ -95,29 +95,29 @@ when C-2's ADR is accepted and no major finding remains.
1. **C-1 closed correctly.** The take-shape landed as the review's 1. **C-1 closed correctly.** The take-shape landed as the review's
first sketch: `BindSession::await_inbound` takes the inner stream first sketch: `BindSession::await_inbound` takes the inner stream
before reading reply#2 (`inner.take()` — consumer.rs:792-803); a before reading reply#2 (`inner.take()`, at consumer.rs:792-803); a
second call hits the `None` arm → the spent shape `Ok(None)` second call hits the `None` arm → the spent shape `Ok(None)`
(consumer.rs:793-795). The returned `BindDataPlane` **owns** the (consumer.rs:793-795). The returned `BindDataPlane` **owns** the
stream (the `TakenInner` pairing posture; the session stays the stream (the `TakenInner` pairing posture; the session stays the
teardown owner) — the re-read is structurally impossible, not teardown owner), making the re-read structurally impossible, not
just guarded. A failed read restores the inner (the phase is just guarded. A failed read restores the inner (the phase is
consumed on success only — the post-failure re-call re-reads consumed on success only; the post-failure re-call re-reads
against the already-ended session, EOFs, never a live data-plane against the already-ended session, EOFs, never a live data-plane
re-read). Pinned by re-read). Pinned by
`double_await_inbound_returns_the_spent_shape_and_bytes_survive` `double_await_inbound_returns_the_spent_shape_and_bytes_survive`
(tests/consumer-bind.rs:497 — the second call `Ok(None)` + the (tests/consumer-bind.rs:497: the second call `Ok(None)` + the
first plane's echo intact + a fresh session round-trips) and first plane's echo intact + a fresh session round-trips) and
`reply1_twice_is_a_view_and_the_reader_yields_one_plane` `reply1_twice_is_a_view_and_the_reader_yields_one_plane`
(tests/consumer-bind.rs:583). **D-1's reader decision is (tests/consumer-bind.rs:583). **D-1's reader decision is
recorded**: option (a) — the session's `await_inbound` is the one recorded**: option (a), where the session's `await_inbound` is the one
normative reply#2 reader; `BindReply1::await_inbound` is cut, normative reply#2 reader; `BindReply1::await_inbound` is cut,
`reply1()` is a documented view (`bind_addr` + `raw_mut`) — and `reply1()` is a documented view (`bind_addr` + `raw_mut`),
recorded in the fix task's Notes and the module docs recorded in the fix task's Notes and the module docs
(consumer.rs:574-579, 762-765). (consumer.rs:574-579, 762-765).
2. **C-3 closed.** The substrate-free entry's BIND gate is the 2. **C-3 closed.** The substrate-free entry's BIND gate is the
builder's explicit `bind_policy` (`.bind_policy(bool)` — builder's explicit `bind_policy` (`.bind_policy(bool)`:
producer.rs:736-739, resolved once at `build()`, producer.rs:736-739, resolved once at `build()`,
`self.bind_policy.unwrap_or(self.accept.is_some())` — `self.bind_policy.unwrap_or(self.accept.is_some())` per
producer.rs:741-748); "factory wired" no longer implies "BIND producer.rs:741-748); "factory wired" no longer implies "BIND
permitted" (the presence reading is only the backward-compat permitted" (the presence reading is only the backward-compat
default). Pinned by default). Pinned by
@@ -126,7 +126,7 @@ when C-2's ADR is accepted and no major finding remains.
ASSOCIATE still serve) and ASSOCIATE still serve) and
`builder_default_bind_policy_follows_accept_presence` (the `builder_default_bind_policy_follows_accept_presence` (the
default both ways). The pump handler's identity-derived gate is default both ways). The pump handler's identity-derived gate is
byte-identical (producer.rs:181-185) — the identity matrix byte-identical (producer.rs:181-185), and the identity matrix
(`pump_handler_resolves_the_bind_gate_from_the_opener_identity`) (`pump_handler_resolves_the_bind_gate_from_the_opener_identity`)
stayed green untouched. stayed green untouched.
3. **D-2 closed.** The spec'd assembly shape is exercised end to 3. **D-2 closed.** The spec'd assembly shape is exercised end to
@@ -141,12 +141,12 @@ when C-2's ADR is accepted and no major finding remains.
4. **N-1/N-4 landed coherently.** The cut list is gone 4. **N-1/N-4 landed coherently.** The cut list is gone
(`auth_method_enums!`, `StandardAuthentication` + `allow_no_auth`, (`auth_method_enums!`, `StandardAuthentication` + `allow_no_auth`,
`skip_auth_this_is_not_rfc_compliant`, `CheckResult`'s `skip_auth_this_is_not_rfc_compliant`, `CheckResult`'s
`Option`/`Result` impls — swept: zero references anywhere); the `Option`/`Result` impls, all swept: zero references anywhere); the
keep list carries current notes (`ConnectError`'s keep list carries current notes (`ConnectError`'s
stream.rs note records the fired trigger + the keep disposition; stream.rs note records the fired trigger + the keep disposition;
`transfer`/`wait_on_tcp` differential-suite-consumed; `Bug` `transfer`/`wait_on_tcp` differential-suite-consumed; `Bug`
upstream-verbatim). The refusal vocabulary is honest at all five upstream-verbatim). The refusal vocabulary is honest at all five
sites — `SocksServerError::Refused(&'static str)` ("Refused: {0}") sites: `SocksServerError::Refused(&'static str)` ("Refused: {0}")
at the command gate + the four seam refusals (producer.rs at the command gate + the four seam refusals (producer.rs
237/269/309/502/560) plus the harness's minimal producer 237/269/309/502/560) plus the harness's minimal producer
(tests/harness.rs:294); `Bug`'s upstream-verbatim display intact. (tests/harness.rs:294); `Bug`'s upstream-verbatim display intact.
@@ -157,13 +157,13 @@ when C-2's ADR is accepted and no major finding remains.
`last_updated` line; `src/rfc1928/LICENSE`'s generic `last_updated` line; `src/rfc1928/LICENSE`'s generic
post-fork clause needed no change. post-fork clause needed no change.
5. **D-3 resolved (the declare side).** `socks5_open_spec` declares 5. **D-3 resolved (the declare side).** `socks5_open_spec` declares
the `channel:open_failed` `ErrorDefinition` (params.rs:139-151 — the `channel:open_failed` `ErrorDefinition` (params.rs:139-151,
the alktty-ported `{reason, message}` schema, the reason enum the alktty-ported `{reason, message}` schema, the reason enum
exactly `SocksEstablishmentReason`'s parse table), doc-vs-code exactly `SocksEstablishmentReason`'s parse table), doc-vs-code
drift gone. Pinned by drift gone. Pinned by
`open_spec_declares_the_open_failed_error_definition` `open_spec_declares_the_open_failed_error_definition`
(params.rs:256-292), which cross-checks every declared enum value (params.rs:256-292), which cross-checks every declared enum value
through the typed parse table — the schema and the typed through the typed parse table, so the schema and the typed
vocabulary cannot drift. vocabulary cannot drift.
6. **N-8 coverage re-run and attributed.** `cargo llvm-cov` on the 6. **N-8 coverage re-run and attributed.** `cargo llvm-cov` on the
final tree: **96.30% regions / 96.43% functions / 97.28% lines** final tree: **96.30% regions / 96.43% functions / 97.28% lines**
@@ -174,25 +174,25 @@ when C-2's ADR is accepted and no major finding remains.
version pin, the quiet-wait `Err(_)` arm, the Domain-peer version pin, the quiet-wait `Err(_)` arm, the Domain-peer
fallback, the `Debug`/`Display` renders). The residual uncovered fallback, the `Debug`/`Display` renders). The residual uncovered
lines are exactly the task's recorded set: producer.rs 188-189 lines are exactly the task's recorded set: producer.rs 188-189
(the `accept_bi` substrate-failure log arm — untestable outside (the `accept_bi` substrate-failure log arm, untestable outside
alkcall's test surface), `rfc1928/client.rs` 217-219 + alkcall's test surface), `rfc1928/client.rs` 217-219 +
internals (the review-001/002 accepted-uncovered posture), internals (the review-001/002 accepted-uncovered posture),
`rfc1928/mod.rs` 136-141 / `target_addr.rs` 132/151/180 (the `rfc1928/mod.rs` 136-141 / `target_addr.rs` 132/151/180 (the
fork's pre-existing residue), plus test-module panic-guard fork's pre-existing residue), plus test-module panic-guard
branches (coverage-of-tests noise) and consumer.rs 937 (the branches (coverage-of-tests noise) and consumer.rs 937 (the
spent-shape diagnostic closure — evaluates only on `Err`, the spent-shape diagnostic closure, which evaluates only on `Err`, the
spent shape is `Ok(None)` by design; cosmetic, not chased). spent shape is `Ok(None)` by design; cosmetic, not chased).
No new load-bearing gaps introduced. No new load-bearing gaps introduced.
7. **N-5/N-6 consolidation verified.** The shared module 7. **N-5/N-6 consolidation verified.** The shared module
(tests/common/mod.rs, 314 lines) compiles into both crates via (tests/common/mod.rs, 314 lines) compiles into both crates via
the crate-root `#[cfg(test)] #[path]` mount + the the crate-root `#[cfg(test)] #[path]` mount + the
`extern crate self` alias (lib.rs:73-82) and harness's `extern crate self` alias (lib.rs:73-82) and harness's
name-preserving re-exports — the lib tests and the integration name-preserving re-exports, so the lib tests and the integration
suite build against the same copy; no `tests/harness.rs` import suite build against the same copy; no `tests/harness.rs` import
from `src/`. The deliberately-distinct shapes stayed test-local from `src/`. The deliberately-distinct shapes stayed test-local
(`HotNoneEgress`/`PureHotNoneEgress`/the drop-witness egresses); (`HotNoneEgress`/`PureHotNoneEgress`/the drop-witness egresses);
the constructor prologue is single-sourced the constructor prologue is single-sourced
(`open_and_negotiate`, consumer.rs:85-108 — the (`open_and_negotiate`, consumer.rs:85-108: the
teardown-at-birth invariant in one place; N-6). teardown-at-birth invariant in one place; N-6).
8. **C-2 decided.** ADR-014 exists 8. **C-2 decided.** ADR-014 exists
(docs/architecture/decisions/014-idle-timeout-posture.md, status (docs/architecture/decisions/014-idle-timeout-posture.md, status
@@ -205,7 +205,7 @@ when C-2's ADR is accepted and no major finding remains.
no-in-band-vocabulary bullet; producer.md §The Open Handler + no-in-band-vocabulary bullet; producer.md §The Open Handler +
the local-backend section + the decision table). the local-backend section + the decision table).
9. **Cross-cutting sweep green** (below); the batch boundary holds 9. **Cross-cutting sweep green** (below); the batch boundary holds
(no `local`/`composed` code exists — the features are declared (no `local`/`composed` code exists, since the features are declared
empty in Cargo.toml per ADR-009, no `cfg(feature)` in `src/`). empty in Cargo.toml per ADR-009, no `cfg(feature)` in `src/`).
### Findings (this review round) ### Findings (this review round)
@@ -213,15 +213,15 @@ when C-2's ADR is accepted and no major finding remains.
No correctness, security, or coverage findings. One minor doc No correctness, security, or coverage findings. One minor doc
residue, **fixed in this review change** (the `F-1` treatment): residue, **fixed in this review change** (the `F-1` treatment):
- **F-1 [minor] — review-numbering collision**: three code - **F-1 [minor]: review-numbering collision.** Three code
comments/docs and two sites in the review doc cited comments/docs and two sites in the review doc cited
"review 002 R-1/R-2/R-4", but those R-findings belong to the "review 002 R-1/R-2/R-4", but those R-findings belong to the
batch-1 gate review (`tasks/implementation/review-protocol-core.md` batch-1 gate review (`tasks/implementation/review-protocol-core.md`,
— the unnumbered inline review); "review 002" now unambiguously the unnumbered inline review); "review 002" now unambiguously
means the C/S/D/N general review this task gates. An operator (or means the C/S/D/N general review this task gates. An operator (or
a later sweep) following "review 002 R-1" would land in the wrong a later sweep) following "review 002 R-1" would land in the wrong
document. Fixed at all six sites (producer.rs ×2, consumer.rs, document. Fixed at all six sites (producer.rs ×2, consumer.rs,
egress.rs, the review doc ×2) — reworded to "the gate review's egress.rs, the review doc ×2) with a rewording to "the gate review's
R-N (tasks/implementation/review-protocol-core.md)". Zero code R-N (tasks/implementation/review-protocol-core.md)". Zero code
impact; the review doc's remaining in-body `R-1`/`R-2` mentions impact; the review doc's remaining in-body `R-1`/`R-2` mentions
ride the references section, which already names the task file. ride the references section, which already names the task file.
@@ -241,13 +241,13 @@ residue, **fixed in this review change** (the `F-1` treatment):
- `cargo check` + `clippy -D warnings` (wasm32): clean. - `cargo check` + `clippy -D warnings` (wasm32): clean.
- `cargo publish --dry-run --allow-dirty`: completes. - `cargo publish --dry-run --allow-dirty`: completes.
- `cargo llvm-cov`: 96.30% regions / 96.43% functions / 97.28% - `cargo llvm-cov`: 96.30% regions / 96.43% functions / 97.28%
lines (5142 covered / 140 missed) — the per-module table: lines (5142 covered / 140 missed); the per-module table:
consumer 98.60% lines, egress 99.67%, error 100%, params 100%, consumer 98.60% lines, egress 99.67%, error 100%, params 100%,
producer 95.70%, wire 96.58%, rfc1928 96.53–99.39% (the producer 95.70%, wire 96.58%, rfc1928 96.53–99.39% (the
accepted-uncovered fork blocks ride client.rs's 96.53%). accepted-uncovered fork blocks ride client.rs's 96.53%).
- **Convention compliance re-swept**: every `unwrap`/`expect`/ - **Convention compliance re-swept**: every `unwrap`/`expect`/
`panic!`/`todo!`/`unimplemented!` hit across all of `src/` `panic!`/`todo!`/`unimplemented!` hit across all of `src/`
(eleven files) mechanically attributed — all inside `#[cfg(test)]` (eleven files) mechanically attributed; all are inside `#[cfg(test)]`
regions; no panics in non-test code; comment posture held (doc regions; no panics in non-test code; comment posture held (doc
comments + pinned correctness notes only); no `cfg(feature)` comments + pinned correctness notes only); no `cfg(feature)`
outside Cargo.toml declarations. outside Cargo.toml declarations.
@@ -260,7 +260,7 @@ the full sweep is green; the fix-round's shapes (the take-guard, the
`bind_policy` gate, the `Refused` vocabulary, the declared `bind_policy` gate, the `Refused` vocabulary, the declared
`ErrorDefinition`) are the shapes batch 2's door tasks consume. `ErrorDefinition`) are the shapes batch 2's door tasks consume.
The next general review doc is `003-*` (this checkpoint was an The next general review doc is `003-*` (this checkpoint was an
inline review in `tasks/` — it consumed none of the inline review in `tasks/`, so it consumed none of the
`docs/reviews/` numbering). `docs/reviews/` numbering).
## Summary ## Summary
+49 -49
View File
@@ -1,6 +1,6 @@
--- ---
id: review-protocol-core id: review-protocol-core
name: Review — the protocol core before any assembly-layer work name: Review (the protocol core before any assembly-layer work)
status: completed status: completed
depends_on: [implementation/core-integration-suite] depends_on: [implementation/core-integration-suite]
scope: narrow scope: narrow
@@ -13,8 +13,8 @@ level: review
Review checkpoint before batch 2 (the assembly layer: `local` Review checkpoint before batch 2 (the assembly layer: `local`
providers, front doors, `composed` providers). Everything in batch 2 providers, front doors, `composed` providers). Everything in batch 2
consumes the seams and the session types, so a core defect — a seam consumes the seams and the session types, so a core defect (a seam
contract drift, a teardown hole, a wasm regression — would propagate contract drift, a teardown hole, a wasm regression) would propagate
into every downstream task. This review is the gate between the core into every downstream task. This review is the gate between the core
group and the assembly layer, and the point where batch 2 gets group and the assembly layer, and the point where batch 2 gets
decomposed (its task shapes may adjust to what the core actually decomposed (its task shapes may adjust to what the core actually
@@ -23,8 +23,8 @@ landed).
Review against the architecture, in this order: Review against the architecture, in this order:
1. **Seam contracts** (ADR-004, producer.md §The Three Seams): the 1. **Seam contracts** (ADR-004, producer.md §The Three Seams): the
readiness contract (`next_reply` `Pending` when nothing is ready — readiness contract (`next_reply` `Pending` when nothing is ready,
the contract test exists and pins it), ownership-rides-the-handle with the contract test in place pinning it), ownership-rides-the-handle
(no `_` bindings of owning values; handles own their payloads (no `_` bindings of owning values; handles own their payloads
structurally), the accept future owning its resources end-to-end structurally), the accept future owning its resources end-to-end
(the quiet-wait drop IS the client-gone teardown), framing-once (the quiet-wait drop IS the client-gone teardown), framing-once
@@ -37,9 +37,9 @@ Review against the architecture, in this order:
3. **Access control** (ADR-006): the open gate (`socks5:open` at the 3. **Access control** (ADR-006): the open gate (`socks5:open` at the
registry), the command gate (`socks5:bind` at BIND dispatch), registry), the command gate (`socks5:bind` at BIND dispatch),
two grants never implied; refusals RFC-faithful everywhere two grants never implied; refusals RFC-faithful everywhere
(convention 2 — never collapsed into generic errors). (convention 2, never collapsed into generic errors).
4. **Teardown/lifecycle** (wire.md §Sentinels, alkcall ADR-050): 4. **Teardown/lifecycle** (wire.md §Sentinels, alkcall ADR-050):
`pump_bidi` awaited inline (R-02 — no spawn-and-forget); the `pump_bidi` awaited inline (R-02, no spawn-and-forget); the
teardown matrix tests actually pin the semantics; no teardown matrix tests actually pin the semantics; no
teardown-at-birth paths (the third-witness pattern held). teardown-at-birth paths (the third-witness pattern held).
5. **Convention compliance** (AGENTS.md): no `unwrap`/`expect`/ 5. **Convention compliance** (AGENTS.md): no `unwrap`/`expect`/
@@ -54,14 +54,14 @@ Review against the architecture, in this order:
7. **POC-fidelity spot-check**: for two or three of the trickiest 7. **POC-fidelity spot-check**: for two or three of the trickiest
behaviors (the readiness contract, the quiet-wait two-exit behaviors (the readiness contract, the quiet-wait two-exit
semantics, the split-datagram reader persistence), diff the semantics, the split-datagram reader persistence), diff the
crate's behavior against the POC crates' findings — the POCs are crate's behavior against the POC crates' findings, since the POCs are
the empirical record; drift is a finding. the empirical record; drift is a finding.
8. **Docs sync**: the specs match what landed (the integration 8. **Docs sync**: the specs match what landed (the integration
task's reconciliation verified; any residue is a finding). task's reconciliation verified; any residue is a finding).
## Notes ## Notes
> Filled by the review agent — see the Summary and the Findings. > Filled by the review agent: see the Summary and the Findings.
## Summary ## Summary
@@ -71,16 +71,16 @@ gate opens. Two findings were corrected in the review change itself
three minor residues (R-3..R-5, fixed in the same change); no three minor residues (R-3..R-5, fixed in the same change); no
seam-contract, wire-fidelity, ACL, teardown, wasm, or POC-fidelity seam-contract, wire-fidelity, ACL, teardown, wasm, or POC-fidelity
drift was found. One prior-session reconciliation (the bind-only drift was found. One prior-session reconciliation (the bind-only
identity row) was independently verified as correct — see the Notes. identity row) was independently verified as correct (see the Notes).
**1. Seam contracts (ADR-004, producer.md §The Three Seams) — clean, **1. Seam contracts (ADR-004, producer.md §The Three Seams): clean,
one finding corrected (R-1).** The readiness contract is documented one finding corrected (R-1).** The readiness contract is documented
on the trait (`egress.rs:174-185`), table-tested, and pinned on the trait (`egress.rs:174-185`), table-tested, and pinned
behaviorally twice (the producer unit test's mutex-guarded hot behaviorally twice (the producer unit test's mutex-guarded hot
egress + the integration suite's hot-`Ok(None)` under concurrent egress + the integration suite's hot-`Ok(None)` under concurrent
load). Ownership rides the handle structurally (`TargetHandle`/ load). Ownership rides the handle structurally (`TargetHandle`/
`BindTargetHandle` payloads own their halves; no `_` bindings of `BindTargetHandle` payloads own their halves; no `_` bindings of
owning values anywhere in the crate — swept); the accept future owns owning values anywhere in the crate, swept clean); the accept future owns
its resources end-to-end (`BindAccept::new` boxes it; the quiet its resources end-to-end (`BindAccept::new` boxes it; the quiet
wait's `select!`-pinned drop is the client-gone teardown, pinned by wait's `select!`-pinned drop is the client-gone teardown, pinned by
the drop-flag tests in `src/producer.rs` and `tests/core- the drop-flag tests in `src/producer.rs` and `tests/core-
@@ -90,16 +90,16 @@ the only prefixers; `frame == frame_inner` byte-identity is
table-tested). R-1 is the readiness contract's own tolerance: see table-tested). R-1 is the readiness contract's own tolerance: see
the Findings. the Findings.
**2. Wire fidelity — clean.** The datagram frame matches wire.md §The **2. Wire fidelity: clean.** The datagram frame matches wire.md §The
Data Plane byte-for-byte: `[len: u16 BE][addr + data]`, the joint Data Plane byte-for-byte: `[len: u16 BE][addr + data]`, the joint
bound (`frame`/`frame_inner` reject > 65535 with the exact per-form bound (`frame`/`frame_inner` reject > 65535 with the exact per-form
maxima pinned: 65528 v4 / 65516 v6 / 65276 domain), F-2 at payload maxima pinned: 65528 v4 / 65516 v6 / 65276 domain), F-2 at payload
level (empty datagram round-trips end to end, never EOF; `len == 0` level (empty datagram round-trips end to end, never EOF; `len == 0`
inner is `EmptyFrame` — unreachable from a compliant peer), RSV/FRAG inner is `EmptyFrame` and unreachable from a compliant peer), RSV/FRAG
absent from the in-band codec. The phase model holds: no mode byte; absent from the in-band codec. The phase model holds: no mode byte;
post-reply garbage is refused structurally in all three commands post-reply garbage is refused structurally in all three commands
(CONNECT = pure pipe, ASSOCIATE = garbage ATYP fails loud, BIND = (CONNECT = pure pipe, ASSOCIATE = garbage ATYP fails loud, BIND =
the quiet wait's one-byte read ends the session with no reply#2) — the quiet wait's one-byte read ends the session with no reply#2),
each pinned by test. Sentinels verified: ASSOCIATE's `127.0.0.1:0` each pinned by test. Sentinels verified: ASSOCIATE's `127.0.0.1:0`
(asserted in the integration suite + the raw probes), BIND's (asserted in the integration suite + the raw probes), BIND's
variant-3 zeros (`0.0.0.0:0` reply#1 + reply#2, pinned); variant-3 zeros (`0.0.0.0:0` reply#1 + reply#2, pinned);
@@ -107,22 +107,22 @@ association lifetime = channel lifetime (EOF rows in the teardown
matrix). The BAST document's joint-constraint note matches the matrix). The BAST document's joint-constraint note matches the
codec. codec.
**3. Access control (ADR-006) — clean.** The open gate is **3. Access control (ADR-006): clean.** The open gate is
`required_scopes = ["socks5:open"]` on the single op spec `required_scopes = ["socks5:open"]` on the single op spec
(`socks5_open_spec`); the command gate resolves `socks5:bind` from (`socks5_open_spec`); the command gate resolves `socks5:bind` from
the per-call opener identity at BIND dispatch and precedes the the per-call opener identity at BIND dispatch and precedes the
accept seam (a scope-less BIND never mints a listener — pinned). accept seam (a scope-less BIND never mints a listener, which is pinned).
Both refusals are RFC-faithful (0x02 at the command gate; the Both refusals are RFC-faithful (0x02 at the command gate; the
registry's `FORBIDDEN` at the open gate); the mapping tables registry's `FORBIDDEN` at the open gate); the mapping tables
(dial/egress/accept/factory errors → reply codes) are table-tested (dial/egress/accept/factory errors → reply codes) are table-tested
and route through the forked `ReplyError` — nothing collapsed into and route through the forked `ReplyError`, with nothing collapsed into
generic errors. The integration suite's identity matrix pins all generic errors. The integration suite's identity matrix pins all
three rows end to end with the CF-006 witness asserted. three rows end to end with the CF-006 witness asserted.
**4. Teardown/lifecycle — clean.** `pump_bidi` is awaited inline in **4. Teardown/lifecycle: clean.** `pump_bidi` is awaited inline in
all three data planes (CONNECT, BIND post-reply#2) inside the all three data planes (CONNECT, BIND post-reply#2) inside the
handler task; the `JoinHandle` tracks the data-plane lifetime (R-02 handler task; the `JoinHandle` tracks the data-plane lifetime (R-02
held — no spawn-and-forget). The teardown matrix tests pin the real held, so no spawn-and-forget). The teardown matrix tests pin the real
semantics (half-close / EOF / client-gone-with-parked-accept, each semantics (half-close / EOF / client-gone-with-parked-accept, each
followed by fresh sessions of every command). No teardown-at-birth followed by fresh sessions of every command). No teardown-at-birth
paths: the egress-drop witness test flips the flag on stage exit, paths: the egress-drop witness test flips the flag on stage exit,
@@ -130,7 +130,7 @@ the accept-drop witness flips on the client-gone branch; the
consumer sessions reap on `close`/`shutdown`/`Drop` with the leak consumer sessions reap on `close`/`shutdown`/`Drop` with the leak
probes asserting no phantom channels on either side. probes asserting no phantom channels on either side.
**5. Convention compliance — one finding corrected (R-2).** No **5. Convention compliance: one finding corrected (R-2).** No
`unwrap`/`expect`/`panic!`/`unreachable!` in non-test library code `unwrap`/`expect`/`panic!`/`unreachable!` in non-test library code
after R-2 (swept: `src/` non-test regions are clean; the forked after R-2 (swept: `src/` non-test regions are clean; the forked
core's panic translations were re-verified). `thiserror` surfaces core's panic translations were re-verified). `thiserror` surfaces
@@ -140,7 +140,7 @@ re-exports) hold; producer/consumer vocabulary holds (RFC-facing
home). Cargo.toml's two inline policy comments are the home). Cargo.toml's two inline policy comments are the
extraction-review F-3-accepted posture (carried, no fix demanded). extraction-review F-3-accepted posture (carried, no fix demanded).
**6. Wasm-clean invariant — green, with one accepted note (R-5).** **6. Wasm-clean invariant: green, with one accepted note (R-5).**
Both wasm checks clean (`cargo check`/`clippy --target Both wasm checks clean (`cargo check`/`clippy --target
wasm32-unknown-unknown -- -D warnings`, from scratch); no wasm32-unknown-unknown -- -D warnings`, from scratch); no
`net`-shaped feature (the two declared features are empty markers); `net`-shaped feature (the two declared features are empty markers);
@@ -149,39 +149,39 @@ tokio on the wasm-clean subset with `default-features = false` (no
R-5: see the Findings (a transitive `getrandom` carrier outside R-5: see the Findings (a transitive `getrandom` carrier outside
this crate's control). this crate's control).
**7. POC-fidelity spot-check — clean, one finding corrected (R-1).** **7. POC-fidelity spot-check: clean, one finding corrected (R-1).**
- *Readiness contract*: the crate's stage matches the POC's - *Readiness contract*: the crate's stage matches the POC's
`select!`-driven shape (channel reads ∥ reply drains, `Ok(None)` `select!`-driven shape (channel reads ∥ reply drains, `Ok(None)`
tolerated as a spurious wakeup); the POC's LocalEgress/ tolerated as a spurious wakeup); the POC's LocalEgress/
ComposedEgress both park properly (`readable()` / queue ComposedEgress both park properly (`readable()` / queue
`recv().await`) and the ComposedEgress's closed-queue arm returns `recv().await`) and the ComposedEgress's closed-queue arm returns
`Err` rather than a hot `None` — exactly the posture the crate `Err` rather than a hot `None`, which is exactly the posture the crate
inherited. The drift the spot-check surfaced is the *zero-yield* inherited. The drift the spot-check surfaced is the *zero-yield*
bound of the contract (R-1) — a pure `async { Ok(None) }` egress bound of the contract (R-1): a pure `async { Ok(None) }` egress
livelocked the landed stage on a current-thread runtime livelocked the landed stage on a current-thread runtime
(empirically reproduced; the POC's findings named the biased-select (empirically reproduced; the POC's findings named the biased-select
and hot-None hazards, the no-yield variant is the same class one and hot-None hazards; the no-yield variant is the same class one
step further). Fixed in the review change. step further). Fixed in the review change.
- *Quiet-wait two-exit semantics*: the landed `select!` is - *Quiet-wait two-exit semantics*: the landed `select!` is
single-shot with exactly the two exits (accept / EOF-garbage-error), single-shot with exactly the two exits (accept / EOF-garbage-error),
not a loop — matches the POC's `never_loop` finding; the parked- not a loop, which matches the POC's `never_loop` finding; the parked-
accept probes make both exits deterministic; reply#1/reply#2 carry accept probes make both exits deterministic; reply#1/reply#2 carry
the factory's `bind_addr`/peer byte-exactly on the provider the factory's `bind_addr`/peer byte-exactly on the provider
variant and the sentinels on variant 3. variant and the sentinels on variant 3.
- *Split-datagram reader persistence*: `AssociateSession::recv_ - *Split-datagram reader persistence*: `AssociateSession::recv_
datagram`'s reader + pending queue persist across calls (pinned by datagram`'s reader + pending queue persist across calls (pinned by
the 2-byte-prefix chunk test and the consumer-associate suite) — the 2-byte-prefix chunk test and the consumer-associate suite),
matches ADR-003's re-read of the POC finding. matching ADR-003's re-read of the POC finding.
**8. Docs sync — clean, three residues fixed in the review change.** **8. Docs sync: clean, with three residues fixed in the review change.**
The integration task's reconciliation was verified against ADR-006 The integration task's reconciliation was verified against ADR-006
§Consequences, producer.md §Scope Gating, and the landed spec/tests §Consequences, producer.md §Scope Gating, and the landed spec/tests
(see the Notes). Residues found and fixed: `src/egress.rs`'s module (see the Notes). Residues found and fixed: `src/egress.rs`'s module
doc still said the readiness tests "land with the producer-associate doc still said the readiness tests "land with the producer-associate
task" (that task is landed — reworded to point at where they live); task" (that task is landed; reworded to point at where they live);
`src/producer.rs`'s establisher doc said "in this task's scope" `src/producer.rs`'s establisher doc said "in this task's scope"
(same class — reworded); producer.md §Wiring's sketch showed (same class, reworded); producer.md §Wiring's sketch showed
`producer.register(&mut channel_core)` — the landed signature takes `producer.register(&mut channel_core)`, while the landed signature takes
`(&ChannelCore, &OperationRegistry, AuthContext, Option<IdentityWitness>)` `(&ChannelCore, &OperationRegistry, AuthContext, Option<IdentityWitness>)`
and also carries the establisher (corrected). The rest of the specs and also carries the establisher (corrected). The rest of the specs
(wire.md, consumer.md §Wiring, ADR-006, rfc1928.md, README Current (wire.md, consumer.md §Wiring, ADR-006, rfc1928.md, README Current
@@ -190,7 +190,7 @@ State, lib.rs crate docs) match the tree.
## Acceptance Criteria ## Acceptance Criteria
- [x] Seam contracts hold (readiness / ownership / accept-future / - [x] Seam contracts hold (readiness / ownership / accept-future /
framing-once) — with R-1 corrected framing-once), with R-1 corrected
- [x] Wire fidelity: the frame, phase model, sentinels, lifetimes - [x] Wire fidelity: the frame, phase model, sentinels, lifetimes
byte-checked against wire.md/bast.md byte-checked against wire.md/bast.md
- [x] Access control: both gates + never-implied grants pinned, the - [x] Access control: both gates + never-implied grants pinned, the
@@ -201,7 +201,7 @@ State, lib.rs crate docs) match the tree.
after R-2; thiserror; naming; module structure; vocabulary after R-2; thiserror; naming; module structure; vocabulary
- [x] Both wasm checks green (from clean), no net-shaped feature, - [x] Both wasm checks green (from clean), no net-shaped feature,
tokio on the wasm-clean subset tokio on the wasm-clean subset
- [x] POC-fidelity spot-check of the three trickiest behaviors — - [x] POC-fidelity spot-check of the three trickiest behaviors:
the zero-yield drift (R-1) found and fixed the zero-yield drift (R-1) found and fixed
- [x] Docs sync: specs match the tree; residues fixed in the same - [x] Docs sync: specs match the tree; residues fixed in the same
change change
@@ -209,11 +209,11 @@ State, lib.rs crate docs) match the tree.
### Findings ### Findings
- **R-1 (major → fixed in the review change)**: the datagram stage's - **R-1 (major, fixed in the review change)**: the datagram stage's
`Ok(None)` tolerance (re-park) is safe against a hot egress only `Ok(None)` tolerance (re-park) is safe against a hot egress only
while its futures yield at least one poll boundary. A *zero-yield* while its futures yield at least one poll boundary. A *zero-yield*
egress — `next_reply` = a pure `async { Ok(None) }`, no await at egress (`next_reply` = a pure `async { Ok(None) }`, no await at
all — wins every `select!` poll forever: the channel branch and all) wins every `select!` poll forever: the channel branch and
every other task on a current-thread scheduler starve (empirically every other task on a current-thread scheduler starve (empirically
reproduced: the echo never arrives; the multi-thread runtime reproduced: the echo never arrives; the multi-thread runtime
survives, so the landed mutex-guarded unit test masked the hazard survives, so the landed mutex-guarded unit test masked the hazard
@@ -222,23 +222,23 @@ State, lib.rs crate docs) match the tree.
handling had one gap. Fix: the stage's `Ok(None)` handler now handling had one gap. Fix: the stage's `Ok(None)` handler now
`yield_now()`s before re-parking (`src/producer.rs` `yield_now()`s before re-parking (`src/producer.rs`
`run_datagram_stage`), plus a pinning unit test with a pure `run_datagram_stage`), plus a pinning unit test with a pure
zero-yield egress (the strictest violator — hangs without the zero-yield egress (the strictest violator, which hangs without the
yield) alongside the existing hot-None test. ADR-003's contract yield) alongside the existing hot-None test. ADR-003's contract
prose and `egress.rs`'s trait doc stay as written (the contract prose and `egress.rs`'s trait doc stay as written (the contract
is on the egress; the stage now additionally defends itself). is on the egress; the stage now additionally defends itself).
- **R-2 (convention, fixed in the review change)**: `Socks5Channel - **R-2 (convention, fixed in the review change)**: `Socks5Channel
Session::into_inner` / `AssociateSession::into_inner` ended in Session::into_inner` / `AssociateSession::into_inner` ended in
`inner.expect("into_inner on a spent session")` — the only `inner.expect("into_inner on a spent session")`, the only
non-test `expect` in `src/`. Reachability is narrow (a spent non-test `expect` in `src/`. Reachability is narrow (a spent
session's inner is gone only after close/shutdown/Drop, and no session's inner is gone only after close/shutdown/Drop, and no
public API constructs a spent session), but convention 2's rule is public API constructs a spent session), but convention 2's rule is
the rule: the shape is now `Result<TakenInner, SpentSessionError>` the rule: the shape is now `Result<TakenInner, SpentSessionError>`
/ `Result<TakenAssociateInner, SpentSessionError>` with a / `Result<TakenAssociateInner, SpentSessionError>` with a
zero-carrier error type (`SpentSessionError` — a spent session's zero-carrier error type (`SpentSessionError`; a spent session's
manager entry is always already reaped, so dropping it loses manager entry is always already reaped, so dropping it loses
nothing; the large-Err/unit-Err lints shaped the choice), pinned nothing; the large-Err/unit-Err lints shaped the choice), pinned
by two spent-session unit tests. Test call sites updated by two spent-session unit tests. Test call sites updated
(`.expect("the session is live")` — test-side, allowed). (`.expect("the session is live")`, which is test-side and allowed).
- **R-3 (doc residue, fixed)**: `src/egress.rs` module docs + the - **R-3 (doc residue, fixed)**: `src/egress.rs` module docs + the
`src/producer.rs` establisher doc carried task-time phrasings `src/producer.rs` establisher doc carried task-time phrasings
("lands with the producer-associate task" / "this task's scope") ("lands with the producer-associate task" / "this task's scope")
@@ -252,7 +252,7 @@ State, lib.rs crate docs) match the tree.
- **R-5 (doc residue, fixed)**: the - **R-5 (doc residue, fixed)**: the
`bind_quiet_wait_is_not_under_the_establishment_bound` test doc `bind_quiet_wait_is_not_under_the_establishment_bound` test doc
claimed the substrate-free probe exercises "the alkcall 10s claimed the substrate-free probe exercises "the alkcall 10s
default bound" — it cannot (the substrate-free shape has no default bound", which it cannot (the substrate-free shape has no
alkcall bound; the over-channels wait-outlives-establishment alkcall bound; the over-channels wait-outlives-establishment
behavior is the harness's pin by construction). Reworded to say behavior is the harness's pin by construction). Reworded to say
what the test actually pins (the wait is teardown-governed, no what the test actually pins (the wait is teardown-governed, no
@@ -286,13 +286,13 @@ State, lib.rs crate docs) match the tree.
- The prior integration session's Notes/Summary were read and - The prior integration session's Notes/Summary were read and
cross-checked against the specs and the tree. The one cross-checked against the specs and the tree. The one
reconciliation it flagged — the task file's identity-matrix row 2 reconciliation it flagged (the task file's identity-matrix row 2
expecting a bind-only identity to serve BIND, vs ADR-006's landed expecting a bind-only identity to serve BIND, vs ADR-006's landed
`required_scopes = ["socks5:open"]` unconditionally — is **correct `required_scopes = ["socks5:open"]` unconditionally) is **correct
as landed**: the op spec (the single op, all three commands, ADR- as landed**: the op spec (the single op, all three commands, ADR-
001) carries the open gate unconditionally; a bind-only identity 001) carries the open gate unconditionally; a bind-only identity
therefore fails closed for every command (FORBIDDEN at the therefore fails closed for every command (FORBIDDEN at the
registry, the establisher never runs, no phantom channel — pinned registry, the establisher never runs, and no phantom channel is pinned
producer- and consumer-side in producer- and consumer-side in
`identity_matrix_bind_only_fails_closed_at_the_open_gate`), and a `identity_matrix_bind_only_fails_closed_at_the_open_gate`), and a
BIND-capable deployment grants both scopes explicitly. The suite BIND-capable deployment grants both scopes explicitly. The suite
@@ -300,14 +300,14 @@ State, lib.rs crate docs) match the tree.
("never implied, in either direction"); the task file's Notes ("never implied, in either direction"); the task file's Notes
record the reasoning. No residue: the drift was a Phase-0-era task record the reasoning. No residue: the drift was a Phase-0-era task
phrasing, not a spec-vs-code contradiction. The user's flag was phrasing, not a spec-vs-code contradiction. The user's flag was
well-placed — this review treats the reconciliation as verified well-placed; this review treats the reconciliation as verified
rather than open. rather than open.
- The self-reported verification claims reproduce (the landed suite - The self-reported verification claims reproduce (the landed suite
was green at review start; this review's fixes re-ran the full was green at review start; this review's fixes re-ran the full
sweep — see below). sweep (see below).
- Post-review verification (the review change included): `cargo - Post-review verification (the review change included): `cargo
test` 348 green (121 lib + 38 connect + 39 associate + 40 bind + test` 348 green (121 lib + 38 connect + 39 associate + 40 bind +
40 integration + 30 differential + 37 rfc1928 + 3 doctests — the 40 integration + 30 differential + 37 rfc1928 + 3 doctests, being the
two R-1/R-2 additions and the four R-2 call-site updates two R-1/R-2 additions and the four R-2 call-site updates
included), `cargo test --all-features` green, `cargo clippy included), `cargo test --all-features` green, `cargo clippy
--all-targets -- -D warnings` clean, `cargo fmt --check` clean, --all-targets -- -D warnings` clean, `cargo fmt --check` clean,
@@ -1,6 +1,6 @@
--- ---
id: review-vendor-extraction id: review-vendor-extraction
name: Review — the vendor extraction before dependent layers name: Review (the vendor extraction before dependent layers)
status: completed status: completed
depends_on: [implementation/vendor-differential-tests] depends_on: [implementation/vendor-differential-tests]
scope: narrow scope: narrow
@@ -13,8 +13,8 @@ level: review
Review checkpoint before any layer builds on the vendor. Everything Review checkpoint before any layer builds on the vendor. Everything
else in the crate (params, wire, producer, consumer, doors, backends) else in the crate (params, wire, producer, consumer, doors, backends)
consumes the vendored typestate/codecs, so extraction errors — a consumes the vendored typestate/codecs, so extraction errors (a
behavior drift vs upstream, a missed cut, a wasm regression — would behavior drift vs upstream, a missed cut, a wasm regression) would
propagate into every downstream task. This review is the gate between propagate into every downstream task. This review is the gate between
the vendor group and the rest of the decomposition. the vendor group and the rest of the decomposition.
@@ -23,7 +23,7 @@ Review against the architecture, in this order:
1. **Extraction completeness vs the cut table** (vendor.md): every 1. **Extraction completeness vs the cut table** (vendor.md): every
"What is Extracted" row is present; every "What is NOT Extracted" "What is Extracted" row is present; every "What is NOT Extracted"
row is absent. Diff `src/vendor/` against row is absent. Diff `src/vendor/` against
`/workspace/fast-socks5/src/` file-by-file — the diff must contain `/workspace/fast-socks5/src/` file-by-file, and the diff must contain
exactly: the extraction cut (deletions per the cut table), the two exactly: the extraction cut (deletions per the cut table), the two
carried changes (`new_reply` publication; the BIND surface being carried changes (`new_reply` publication; the BIND surface being
present-and-public), the extraction-time adjustments (anyhow/log present-and-public), the extraction-time adjustments (anyhow/log
@@ -48,8 +48,8 @@ Review against the architecture, in this order:
5. **License/provenance** (ADR-011): the vendor point (v1.0.0) noted 5. **License/provenance** (ADR-011): the vendor point (v1.0.0) noted
in-tree; the MIT notice present and referenced. in-tree; the MIT notice present and referenced.
6. **Docs sync**: `vendor.md`'s extraction table and cut table match 6. **Docs sync**: `vendor.md`'s extraction table and cut table match
what landed (if the implementer discovered a boundary correction — what landed (if the implementer discovered a boundary correction,
e.g. a transitive dependency the cut table missed — the doc was e.g. a transitive dependency the cut table missed, the doc was
updated in the same change, not silently diverged). updated in the same change, not silently diverged).
## Notes ## Notes
@@ -64,13 +64,13 @@ itself (vendor `mod.rs` + `vendor.md`, same commit); no other
divergence, cut, or drift was found in the full file-by-file diff vs divergence, cut, or drift was found in the full file-by-file diff vs
`/workspace/fast-socks5` v1.0.0. `/workspace/fast-socks5` v1.0.0.
**1. Extraction completeness vs the cut table** — every "What is **1. Extraction completeness vs the cut table**: every "What is
Extracted" row is present; every "What is NOT Extracted" row is Extracted" row is present; every "What is NOT Extracted" row is
absent (swept by name for `Socks5Datagram`/`connect*`/`run_*`/ absent (swept by name for `Socks5Datagram`/`connect*`/`run_*`/
`udp_bind_random_port`/`handle_udp*`/`transfer_udp`/`Socks5Server`/ `udp_bind_random_port`/`handle_udp*`/`transfer_udp`/`Socks5Server`/
`Socks5Socket`/`Incoming`/`DnsResolveHelper`/`Config<A>`/ `Socks5Socket`/`Incoming`/`DnsResolveHelper`/`Config<A>`/
`Authentication`/`authenticate_callback`/auth example impls/`socks4`/ `Authentication`/`authenticate_callback`/auth example impls/`socks4`/
`resolve_dns`/`tcp_connect*` — the only hit is the preserved verbatim `resolve_dns`/`tcp_connect*`, with the only hit being the preserved verbatim
error string in `target_addr.rs` referencing `resolve_dns`, a error string in `target_addr.rs` referencing `resolve_dns`, a
documented fidelity keep). Every hunk of all five file diffs documented fidelity keep). Every hunk of all five file diffs
(`lib.rs`→`mod.rs`, `server.rs`, `client.rs`, `util/stream.rs`, (`lib.rs`→`mod.rs`, `server.rs`, `client.rs`, `util/stream.rs`,
@@ -86,7 +86,7 @@ region is mechanical. No unexplained divergence.
wasm32-unknown-unknown` and `cargo clippy --target wasm32-unknown-unknown` and `cargo clippy --target
wasm32-unknown-unknown -- -D warnings` both clean; no `net`-shaped wasm32-unknown-unknown -- -D warnings` both clean; no `net`-shaped
feature; tokio on the wasm-clean subset (`rt`, `sync`, `io-util`, feature; tokio on the wasm-clean subset (`rt`, `sync`, `io-util`,
`macros`, `time` — no `full`) with `default-features = false`; deps `macros`, `time`, with no `full`) with `default-features = false`; deps
are exactly tokio/tracing/thiserror (dev: full tokio only). are exactly tokio/tracing/thiserror (dev: full tokio only).
**3. Convention compliance**: no `unwrap`/`expect`/`unreachable!`/ **3. Convention compliance**: no `unwrap`/`expect`/`unreachable!`/
@@ -95,18 +95,18 @@ are exactly tokio/tracing/thiserror (dev: full tokio only).
(one module per file, upstream names preserved, `lib.rs` re-exports (one module per file, upstream names preserved, `lib.rs` re-exports
`vendor`); `#![forbid(unsafe_code)]` present. `Cargo.toml`'s two `vendor`); `#![forbid(unsafe_code)]` present. `Cargo.toml`'s two
inline comments are the sole code-adjacent comment in non-doc inline comments are the sole code-adjacent comment in non-doc
position — carried from the scaffolding task's declared posture position, carried from the scaffolding task's declared posture
(ADR-009 feature-layout note); no fix demanded. (ADR-009 feature-layout note); no fix demanded.
**4. RFC fidelity spot-check** (independent): fetched RFC 1928 and **4. RFC fidelity spot-check** (independent): fetched RFC 1928 and
verified against the pinned tests — the reply form (§6: VER/REP/RSV/ verified against the pinned tests: the reply form (§6: VER/REP/RSV/
ATYP/BND.ADDR/BND.PORT → 10 bytes v4, 22 bytes v6, RSV=X'00', codes ATYP/BND.ADDR/BND.PORT → 10 bytes v4, 22 bytes v6, RSV=X'00', codes
`0x00`–`0x08`, `0x09`–`0xFF` unassigned), the method-selection `0x00`–`0x08`, `0x09`–`0xFF` unassigned), the method-selection
preference order (§3 + upstream's outer-loop contract), the UDP preference order (§3 + upstream's outer-loop contract), the UDP
ASSOCIATE zero fallback (§7: "MUST use a port number and address of ASSOCIATE zero fallback (§7: "MUST use a port number and address of
all zeros"), the 255-byte domain cap (§5: 1-byte length, no NUL), and all zeros"), the 255-byte domain cap (§5: 1-byte length, no NUL), and
the FRAG parser/caller split (§7: MUST drop FRAG≠0 — refusal is the the FRAG parser/caller split (§7: MUST drop FRAG≠0, with the refusal being the
caller's, passthrough the parser's). All pinned bytes correct. The caller's and passthrough the parser's). All pinned bytes correct. The
differential suite's 32 tests pass; its upstream file+line citations differential suite's 32 tests pass; its upstream file+line citations
were spot-checked against the reference checkout and are accurate. were spot-checked against the reference checkout and are accurate.
@@ -125,7 +125,7 @@ Config-Default/`write_all` notes; `last_updated` bumped).
## Acceptance Criteria ## Acceptance Criteria
- [x] The file-by-file diff vs upstream contains only - [x] The file-by-file diff vs upstream contains only
cut-table/carried-change/adjustment/provenance changes — no cut-table/carried-change/adjustment/provenance changes, with no
unexplained divergence unexplained divergence
- [x] Both wasm checks pass (`cargo check` and - [x] Both wasm checks pass (`cargo check` and
`cargo clippy --target wasm32-unknown-unknown -- -D warnings`) `cargo clippy --target wasm32-unknown-unknown -- -D warnings`)
@@ -140,9 +140,9 @@ Config-Default/`write_all` notes; `last_updated` bumped).
### Findings ### Findings
- **F-1 (critical → resolved in the review change)**: - **F-1 (critical, resolved in the review change)**:
`ReplyError::from_u8` kept upstream's `unreachable!("ReplyError `ReplyError::from_u8` kept upstream's `unreachable!("ReplyError
code unsupported.")` arm (`src/vendor/mod.rs:240` pre-fix) — code unsupported.")` arm (`src/vendor/mod.rs:240` pre-fix), and it is
wire-reachable: `Socks5Stream::read_request_reply` feeds the raw wire-reachable: `Socks5Stream::read_request_reply` feeds the raw
reply byte from the wire into it (`src/vendor/client.rs:340`), and reply byte from the wire into it (`src/vendor/client.rs:340`), and
RFC 1928 §6 marks `0x09`–`0xFF` unassigned, so a misbehaving peer RFC 1928 §6 marks `0x09`–`0xFF` unassigned, so a misbehaving peer
@@ -150,7 +150,7 @@ Config-Default/`write_all` notes; `last_updated` bumped).
no-panic criterion (the panic lives in `mod.rs`, the vocabulary no-panic criterion (the panic lives in `mod.rs`, the vocabulary
task's file, whose criterion enumerated variants but not panic task's file, whose criterion enumerated variants but not panic
arms). Fix: unassigned codes map to `GeneralFailure` (the RFC's arms). Fix: unassigned codes map to `GeneralFailure` (the RFC's
catch-all) — `src/vendor/mod.rs` `from_u8` + a pinning test catch-all) applied to `src/vendor/mod.rs`'s `from_u8` + a pinning test
(`reply_error_unassigned_codes_map_to_general_failure`); recorded (`reply_error_unassigned_codes_map_to_general_failure`); recorded
in `vendor.md`'s adjustment list (same change, check-6 discipline). in `vendor.md`'s adjustment list (same change, check-6 discipline).
Post-fix suite: 38 + 32 tests, clippy/fmt/wasm/doc green. Post-fix suite: 38 + 32 tests, clippy/fmt/wasm/doc green.
@@ -163,11 +163,11 @@ Config-Default/`write_all` notes; `last_updated` bumped).
- **F-3 (minor, accepted):** `Cargo.toml`'s `[features]` section - **F-3 (minor, accepted):** `Cargo.toml`'s `[features]` section
carries inline comments (AGENTS.md convention 1 says no comments in carries inline comments (AGENTS.md convention 1 says no comments in
code; `Cargo.toml` policy comments mirroring a pinned ADR boundary code; `Cargo.toml` policy comments mirroring a pinned ADR boundary
are the established sibling posture — alktunnels/alktty carry none, are the established sibling posture: alktunnels/alktty carry none,
but the note is ADR-009's empty-features contract, load-bearing but the note is ADR-009's empty-features contract, load-bearing
until the features populate). Accepted; no fix demanded. until the features populate). Accepted; no fix demanded.
- **F-4 (minor, accepted):** `ToSocketAddrs`' error text still names - **F-4 (minor, accepted):** `ToSocketAddrs`' error text still names
the cut `resolve_dns()` — verbatim upstream string, documented in the cut `resolve_dns()`, a verbatim upstream string documented in
the wire-vocabulary task's Notes and the module doc. Fidelity keep. the wire-vocabulary task's Notes and the module doc. Fidelity keep.
## References ## References
@@ -186,7 +186,7 @@ Config-Default/`write_all` notes; `last_updated` bumped).
green), and their boundary corrections were honest and documented green), and their boundary corrections were honest and documented
(`mod.rs` vocabulary placement; `SocksServerError` early landing). (`mod.rs` vocabulary placement; `SocksServerError` early landing).
One inaccuracy found: the wire-vocabulary task's acceptance list One inaccuracy found: the wire-vocabulary task's acceptance list
claimed "No unwrap outside tests" scope as file-local — the F-1 claimed "No unwrap outside tests" scope as file-local, and the F-1
panic arm lived in `mod.rs` and was missed by the per-task framing panic arm lived in `mod.rs` and was missed by the per-task framing
(each task verified its own files); the review's whole-vendor sweep (each task verified its own files); the review's whole-vendor sweep
is what caught it. Task summaries left as-is (historical record); is what caught it. Task summaries left as-is (historical record);
+34 -34
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/vendor-client-session id: implementation/vendor-client-session
name: Vendor — the client session (Socks5Stream::use_stream path) name: "Vendor (the client session, Socks5Stream::use_stream path)"
status: completed status: completed
depends_on: [implementation/vendor-wire-vocabulary] depends_on: [implementation/vendor-wire-vocabulary]
scope: narrow scope: narrow
@@ -11,8 +11,8 @@ level: implementation
## Description ## Description
Extract the client session — the consumer half's stream-session base Extract the client session, the consumer half's stream-session base
(ADR-002; POC #1's fit) — into `src/vendor/client.rs`: the (ADR-002; POC #1's fit) into `src/vendor/client.rs`: the
`Socks5Stream<S>::use_stream` path plus its `AsyncRead`/`AsyncWrite` `Socks5Stream<S>::use_stream` path plus its `AsyncRead`/`AsyncWrite`
impls, generic over the backing stream. The consumer layer (a later impls, generic over the backing stream. The consumer layer (a later
task group) opens a channels `BiStream` and feeds it here; the task group) opens a channels `BiStream` and feeds it here; the
@@ -26,51 +26,51 @@ shape (vendor.md's extraction table).
`which_method_accepted`, `use_password_auth`, `request_header`, `which_method_accepted`, `use_password_auth`, `request_header`,
`read_request_reply`, `get_socket`/`get_socket_ref`/`get_socket_mut`. `read_request_reply`, `get_socket`/`get_socket_ref`/`get_socket_mut`.
2. The `AsyncRead`/`AsyncWrite` impls for `Socks5Stream<S>` (upstream 2. The `AsyncRead`/`AsyncWrite` impls for `Socks5Stream<S>` (upstream
`client.rs:641–681`) verbatim — the consumer's session rides these `client.rs:641–681`) verbatim; the consumer's session rides these
(a `Socks5Stream` is itself a stream, so `pump_bidi`-shaped (a `Socks5Stream` is itself a stream, so `pump_bidi`-shaped
consumers and the front doors can pipe through it). consumers and the front doors can pipe through it).
3. The client `Config` struct — **pruned**: upstream carries 3. The client `Config` struct, **pruned**: upstream carries
`connect_timeout` (only consumed by the cut `connect_raw`) and `connect_timeout` (only consumed by the cut `connect_raw`) and
`skip_auth` (consumed by `use_stream`). Keep `skip_auth`; drop `skip_auth` (consumed by `use_stream`). Keep `skip_auth`; drop
`connect_timeout` and its setter (its only consumer is cut). If `connect_timeout` and its setter (its only consumer is cut). If
dropping it leaves `Config` a single-field struct, keep it anyway dropping it leaves `Config` a single-field struct, keep it anyway
(fidelity: the field is load-bearing in `use_stream`'s handshake (fidelity: the field is load-bearing in `use_stream`'s handshake
branch) or inline the flag — implementer's choice, but the branch) or inline the flag, which is the implementer's choice, but the
`use_stream` behavior must stay byte-identical. `use_stream` behavior must stay byte-identical.
**What is NOT extracted** (vendor.md's cut table row 2): **What is NOT extracted** (vendor.md's cut table row 2):
- `Socks5Datagram` (struct + impl — the kernel `UdpSocket` associate - `Socks5Datagram` (struct + impl, the kernel `UdpSocket` associate
client; the crate's associate session is first-party in client; the crate's associate session is first-party in
`consumer.rs` per ADR-003's handle swap). `consumer.rs` per ADR-003's handle swap).
- The `Socks5Stream<TcpStream>` convenience impl - The `Socks5Stream<TcpStream>` convenience impl
(`connect`/`connect_with_password`/`connect_raw` — the (`connect`/`connect_with_password`/`connect_raw`, the
`tokio::net::TcpStream` dialers; the consumer's transport is a `tokio::net::TcpStream` dialers; the consumer's transport is a
channels `BiStream`, not a TCP dial). channels `BiStream`, not a TCP dial).
**Extraction-time adjustments** (mechanical): **Extraction-time adjustments** (mechanical):
- Drop `anyhow::Context` — upstream wraps nearly every client error in - Drop `anyhow::Context`: upstream wraps nearly every client error in
`.context(...)`. The error surface is `SocksError` (thiserror): `.context(...)`. The error surface is `SocksError` (thiserror):
replace `.context("...")?` with plain `?` where the error variant replace `.context("...")?` with plain `?` where the error variant
already carries the meaning, and with the typed variants already carries the meaning, and with the typed variants
(`SocksError::Io`, `AuthMethodUnacceptable`, ...) where upstream (`SocksError::Io`, `AuthMethodUnacceptable`, ...) where upstream
used `anyhow::Error::msg(...).into()` (upstream `client.rs:285` — used `anyhow::Error::msg(...).into()` (upstream `client.rs:285`, the
the missing-target-addr case) or `SocksError::Other(anyhow!(...))` missing-target-addr case) or `SocksError::Other(anyhow!(...))`
(upstream `client.rs:528` — but that site is inside the cut (upstream `client.rs:528`, but that site is inside the cut
`Socks5Datagram`, so it goes with the cut). No behavior change to `Socks5Datagram`, so it goes with the cut). No behavior change to
the wire: the same bytes are read/written; only the error typing the wire: the same bytes are read/written; only the error typing
changes. changes.
- Drop `log` calls (`debug!` in the extracted region). - Drop `log` calls (`debug!` in the extracted region).
- `use_password_auth`'s `unreachable!()` (upstream `client.rs:207` — - `use_password_auth`'s `unreachable!()` (upstream `client.rs:207`:
`methods.get(1)` being `Some(AuthenticationMethod::None)`): `methods.get(1)` being `Some(AuthenticationMethod::None)`):
convention 2 forbids panics in library code. Replace with a typed convention 2 forbids panics in library code. Replace with a typed
error path (`SocksError::AuthenticationRejected(...)` or an error path (`SocksError::AuthenticationRejected(...)` or an
`AuthMethodUnacceptable`) — the state is reachable only by misuse `AuthMethodUnacceptable`); the state is reachable only by misuse
of the public API, but the faithful translation is an error return, of the public API, but the faithful translation is an error return,
not a panic. not a panic.
- `client_src`-style `"[::]:0".parse().unwrap()` sites are all inside - `client_src`-style `"[::]:0".parse().unwrap()` sites are all inside
the cut `Socks5Datagram` region — verify none remain. the cut `Socks5Datagram` region, so verify none remain.
## Acceptance Criteria ## Acceptance Criteria
@@ -87,9 +87,9 @@ shape (vendor.md's extraction table).
- [x] `Socks5Stream::use_stream` over an in-memory duplex completes: - [x] `Socks5Stream::use_stream` over an in-memory duplex completes:
no-auth handshake, password handshake (both accept and reject no-auth handshake, password handshake (both accept and reject
paths), CONNECT request + reply read (success and each mapped paths), CONNECT request + reply read (success and each mapped
failure code), domain-target and IP-target request encoding — failure code), domain-target and IP-target request encoding,
against a raw-byte mock server (pinned byte sequences written against a raw-byte mock server (pinned byte sequences written
directly, not the vendored server typestate — keeping this directly, not the vendored server typestate; this keeps this
task parallel with the typestate extraction and the client's task parallel with the typestate extraction and the client's
wire expectations uncorrelated from the server implementation; wire expectations uncorrelated from the server implementation;
tokio duplex streams, no sockets) tokio duplex streams, no sockets)
@@ -106,7 +106,7 @@ shape (vendor.md's extraction table).
- docs/architecture/decisions/002-protocol-base.md (the client side: - docs/architecture/decisions/002-protocol-base.md (the client side:
`Socks5Stream<S>::use_stream`) `Socks5Stream<S>::use_stream`)
- docs/architecture/decisions/003-udp-handle-swap.md (why - docs/architecture/decisions/003-udp-handle-swap.md (why
`Socks5Datagram` is cut — the associate session is first-party) `Socks5Datagram` is cut, since the associate session is first-party)
- docs/research/poc-connect-wasm-findings.md §Wire/API notes - docs/research/poc-connect-wasm-findings.md §Wire/API notes
(`Socks5Stream::request` target construction; the (`Socks5Stream::request` target construction; the
`AuthenticationRejected` unit-variant note) `AuthenticationRejected` unit-variant note)
@@ -118,13 +118,13 @@ shape (vendor.md's extraction table).
- **Verbatim-code lint posture**: no module-level clippy allows were - **Verbatim-code lint posture**: no module-level clippy allows were
needed for this file (unlike `server.rs`'s needed for this file (unlike `server.rs`'s
`wrong_self_convention`) — upstream's client region compiles clean `wrong_self_convention`): upstream's client region compiles clean
under the newer clippy after the mechanical adjustments. The under the newer clippy after the mechanical adjustments. The
posture note stays in the module doc for the re-vendor diff. posture note stays in the module doc for the re-vendor diff.
- **Clippy `unused_io_amount` fix (the one non-verbatim line)**: - **Clippy `unused_io_amount` fix (the one non-verbatim line)**:
upstream's `request_header` writes with `self.socket.write(&packet upstream's `request_header` writes with `self.socket.write(&packet
[..padding])` (upstream `client.rs:331`); in-tree it is [..padding])` (upstream `client.rs:331`); in-tree it is
`write_all` — `deny(clippy::unused_io_amount)` is on by default and `write_all`; `deny(clippy::unused_io_amount)` is on by default and
a partial write of a SOCKS5 request header would corrupt the a partial write of a SOCKS5 request header would corrupt the
conversation anyway, so `write_all` is the faithful translation conversation anyway, so `write_all` is the faithful translation
(byte-identical on the wire for a healthy stream; strictly better (byte-identical on the wire for a healthy stream; strictly better
@@ -132,25 +132,25 @@ shape (vendor.md's extraction table).
packet is length-limited) is upstream's, preserved. packet is length-limited) is upstream's, preserved.
- **`anyhow::Error::msg("target addr should be present").into()`** - **`anyhow::Error::msg("target addr should be present").into()`**
(upstream `client.rs:285`) became the typed (upstream `client.rs:285`) became the typed
`SocksError::MissingTargetAddr` variant — added to `SocksError` in `SocksError::MissingTargetAddr` variant, added to `SocksError` in
`mod.rs` (the wire-vocabulary task's error enum, extended by one `mod.rs` (the wire-vocabulary task's error enum, extended by one
variant; the message text is upstream's, kept as the variant's variant; the message text is upstream's, kept as the variant's
`#[error]` string). This is the task's "typed variants where `#[error]` string). This is the task's "typed variants where
upstream used `anyhow::Error::msg(...).into()`" adjustment; the upstream used `anyhow::Error::msg(...).into()`" adjustment; the
variant is wire-inert (error surface only). variant is wire-inert (error surface only).
- **`use_password_auth`'s `unreachable!()`** (upstream - **`use_password_auth`'s `unreachable!()`** (upstream
`client.rs:207`): upstream's match has three arms — `client.rs:207`): upstream's match has three arms,
`Some(Password{..})` → Ok, `Some(None)` → `unreachable!()`, `None` `Some(Password{..})` → Ok, `Some(None)` → `unreachable!()`, `None`
→ `AuthenticationRejected("missing user pass")`. In-tree the → `AuthenticationRejected("missing user pass")`. In-tree the
`Some(None)` and `None` arms collapse to a single `Some(None)` and `None` arms collapse to a single
`AuthenticationRejected("Authentication rejected, missing user `AuthenticationRejected("Authentication rejected, missing user
pass")` return — the same message as upstream's `None` arm, an pass")` return (the same message as upstream's `None` arm, an
error return instead of a panic (convention 2). The state is error return instead of a panic, per convention 2). The state is
reachable only by misuse (`auth = Some(AuthenticationMethod::None)` reachable only by misuse (`auth = Some(AuthenticationMethod::None)`
while the server picks password). while the server picks password).
- **`Config` pruned to `skip_auth`**: `connect_timeout` + its setter - **`Config` pruned to `skip_auth`**: `connect_timeout` + its setter
are cut with `connect_raw` (their only consumer). With one field are cut with `connect_raw` (their only consumer). With one field
left, the struct stayed (fidelity — the field is load-bearing in left, the struct stayed (fidelity: the field is load-bearing in
`use_stream`'s handshake branch); `Default` was derived instead of `use_stream`'s handshake branch); `Default` was derived instead of
the manual impl (upstream's manual `Default` only existed to spell the manual impl (upstream's manual `Default` only existed to spell
the now-cut field). `set_skip_auth` is upstream verbatim. the now-cut field). `set_skip_auth` is upstream verbatim.
@@ -163,8 +163,8 @@ shape (vendor.md's extraction table).
auth-sub-negotiation reply and compared only `is_success`; the auth-sub-negotiation reply and compared only `is_success`; the
destructured `version` fed only a dropped `debug!`. In-tree the destructured `version` fed only a dropped `debug!`. In-tree the
dropped-log rename would have discarded it entirely; instead it is dropped-log rename would have discarded it entirely; instead it is
checked against `SOCKS5_VERSION` (→ `UnsupportedSocksVersion`) — checked against `SOCKS5_VERSION` (→ `UnsupportedSocksVersion`), the
the same check every other reply read in this file performs; the same check every other reply read in this file performs, since the
alternative (an `_version` binding) silently accepts a v4 reply on alternative (an `_version` binding) silently accepts a v4 reply on
the sub-negotiation. Byte-level behavior on a compliant server is the sub-negotiation. Byte-level behavior on a compliant server is
unchanged. unchanged.
@@ -173,8 +173,8 @@ shape (vendor.md's extraction table).
only via formatting; nothing renamed (the field is written on the only via formatting; nothing renamed (the field is written on the
previous line, read in `request_header`). previous line, read in `request_header`).
- **Tests are first-party** (upstream's client test module rides the - **Tests are first-party** (upstream's client test module rides the
cut net region — it tests `connect` against real listeners): the cut net region, testing `connect` against real listeners): the
test module here is written fresh against the crate's test layout — test module here is written fresh against the crate's test layout:
raw-byte mock servers over tokio duplex streams, pinned byte raw-byte mock servers over tokio duplex streams, pinned byte
sequences written directly (uncorrelated from the vendored server sequences written directly (uncorrelated from the vendored server
typestate, per the task). Coverage: no-auth handshake + CONNECT typestate, per the task). Coverage: no-auth handshake + CONNECT
@@ -187,14 +187,14 @@ shape (vendor.md's extraction table).
`AuthMethodUnacceptable`), version mismatch (both greeting `AuthMethodUnacceptable`), version mismatch (both greeting
directions), missing target addr (typed `MissingTargetAddr`), directions), missing target addr (typed `MissingTargetAddr`),
UDP ASSOCIATE zero fallback (`[5,3,0,1,0,0,0,0,0,0]`), UDP ASSOCIATE zero fallback (`[5,3,0,1,0,0,0,0,0,0]`),
`skip_auth` (greeting bypassed — request is the first bytes), `skip_auth` (greeting bypassed, request being the first bytes),
the full mapped failure-code table (0x01–0x08 → the full mapped failure-code table (0x01–0x08 →
`SocksError::ReplyError` with the code round-tripped), domain `SocksError::ReplyError` with the code round-tripped), domain
>255 (→ `ExceededMaxDomainLen(256)`), `tokio::io::copy` through >255 (→ `ExceededMaxDomainLen(256)`), `tokio::io::copy` through
the session (the `AsyncRead`/`AsyncWrite` impls), and the socket the session (the `AsyncRead`/`AsyncWrite` impls), and the socket
accessors (`get_socket_ref`/`get_socket_mut`/`get_socket`). accessors (`get_socket_ref`/`get_socket_mut`/`get_socket`).
- **`request_header` is exercised directly** in two tests (the - **`request_header` is exercised directly** in two tests (the
missing-target and the zero-fallback one) — it is private; the missing-target and the zero-fallback one); since it is private, the
tests live in the same module, so this is in-crate visibility, no tests live in the same module, so this is in-crate visibility, no
surface change. surface change.
@@ -209,11 +209,11 @@ and_methods`, `which_method_accepted`, `use_password_auth`,
impls, and the pruned `Config` (`skip_auth` only). The cut regions impls, and the pruned `Config` (`skip_auth` only). The cut regions
(`Socks5Datagram` struct + impl, the `Socks5Stream<TcpStream>` (`Socks5Datagram` struct + impl, the `Socks5Stream<TcpStream>`
convenience impl with `connect`/`connect_with_password`/ convenience impl with `connect`/`connect_with_password`/
`connect_raw`, `Config::connect_timeout` + setter) were not copied — `connect_raw`, `Config::connect_timeout` + setter) were not copied
verified by sweep (only doc-comment provenance mentions remain). (verified by sweep; only doc-comment provenance mentions remain).
Extraction-time adjustments applied: `anyhow` dropped (the Extraction-time adjustments applied: `anyhow` dropped (the
`client.rs:285` site became the typed `SocksError::MissingTargetAddr` `client.rs:285` site became the typed `SocksError::MissingTargetAddr`
variant — one variant added to the vocabulary task's `SocksError`; variant (one variant added to the vocabulary task's `SocksError`;
the `client.rs:528` `Other(anyhow!(...))` site went with the cut the `client.rs:528` `Other(anyhow!(...))` site went with the cut
`Socks5Datagram`), `log` calls dropped (unused bindings renamed), `Socks5Datagram`), `log` calls dropped (unused bindings renamed),
`use_password_auth`'s `unreachable!()` replaced with an error return `use_password_auth`'s `unreachable!()` replaced with an error return
@@ -1,6 +1,6 @@
--- ---
id: implementation/vendor-crate-scaffolding id: implementation/vendor-crate-scaffolding
name: Vendor — crate scaffolding (Cargo.toml, lib.rs, vendor module skeleton, license notice) name: "Vendor (crate scaffolding: Cargo.toml, lib.rs, vendor module skeleton, license notice)"
status: completed status: completed
depends_on: [] depends_on: []
scope: narrow scope: narrow
@@ -12,7 +12,7 @@ level: implementation
## Description ## Description
Create the `alksocks` crate skeleton so the vendor extraction tasks have Create the `alksocks` crate skeleton so the vendor extraction tasks have
their targets. This is the first implementation work in the repo — `src/` their targets. This is the first implementation work in the repo, as `src/`
does not exist yet. The crate is born wasm-clean: the default build is does not exist yet. The crate is born wasm-clean: the default build is
protocol-only with zero kernel-net code (ADR-009 §Amendment, ADR-011 protocol-only with zero kernel-net code (ADR-009 §Amendment, ADR-011
§Amendment), so the wasm check must be green from the first commit. §Amendment), so the wasm check must be green from the first commit.
@@ -24,38 +24,38 @@ Deliverables:
- `name = "alksocks"`, `version = "0.1.0"`, `edition = "2021"`, - `name = "alksocks"`, `version = "0.1.0"`, `edition = "2021"`,
`rust-version = "1.88"`, `license = "MIT OR Apache-2.0"`, `rust-version = "1.88"`, `license = "MIT OR Apache-2.0"`,
description/keywords/categories/repository per the siblings. description/keywords/categories/repository per the siblings.
- `[features]`: `default = []`, `local = []`, `composed = []` — - `[features]`: `default = []`, `local = []`, `composed = []`,
declared empty now (ADR-009 pins the two-feature layout; the declared empty now (ADR-009 pins the two-feature layout; the
features are populated by their modules' later tasks; `composed` features are populated by their modules' later tasks; `composed`
will add the alktunnels dep, `local` will add `tokio/net` + will add the alktunnels dep, `local` will add `tokio/net` +
`socket2`). `socket2`).
- `[dependencies]`: `tokio = { version = "1", default-features = false, - `[dependencies]`: `tokio = { version = "1", default-features = false,
features = ["rt", "sync", "io-util", "macros", "time"] }` (the features = ["rt", "sync", "io-util", "macros", "time"] }` (the
wasm-clean subset per AGENTS.md convention 4 — **never wasm-clean subset per AGENTS.md convention 4, so **never
`features = ["full"]`**), `thiserror = "2"`, `tracing = "0.1"`. `features = ["full"]`**), `thiserror = "2"`, `tracing = "0.1"`.
**No `alkcall` yet** — the vendor does not need it; the **No `alkcall` yet**, since the vendor does not need it; the
producer/consumer tasks pin `alkcall = "0.8.0"` when they land. producer/consumer tasks pin `alkcall = "0.8.0"` when they land.
- `[dev-dependencies]`: `tokio = { version = "1", features = ["full", - `[dev-dependencies]`: `tokio = { version = "1", features = ["full",
"test-util", "macros"] }` (tests are native-only; they are not "test-util", "macros"] }` (tests are native-only; they are not
part of the wasm-clean surface). part of the wasm-clean surface).
2. **`src/lib.rs`**: `#![forbid(unsafe_code)]`, brief crate docs 2. **`src/lib.rs`**: `#![forbid(unsafe_code)]`, brief crate docs
(what the crate is, pointing at `docs/architecture/overview.md` — (what the crate is, pointing at `docs/architecture/overview.md`
doc comments are fine and expected), `pub mod vendor;`. since doc comments are fine and expected), `pub mod vendor;`.
3. **`src/vendor/` skeleton** preserving upstream file names so per-file 3. **`src/vendor/` skeleton** preserving upstream file names so per-file
diffs against the reference checkout stay mechanical (the diffs against the reference checkout stay mechanical (the
maintenance posture, vendor.md §Maintenance Posture): maintenance posture, vendor.md §Maintenance Posture):
- `src/vendor/mod.rs` — module doc carrying the provenance note: - `src/vendor/mod.rs`: a module doc carrying the provenance note:
extracted from fast-socks5 **v1.0.0** (MIT, third-party upstream), extracted from fast-socks5 **v1.0.0** (MIT, third-party upstream),
per ADR-011; the extraction cut + carried changes are recorded in per ADR-011; the extraction cut + carried changes are recorded in
`docs/architecture/vendor.md`; submodule declarations for `server`, `docs/architecture/vendor.md`; submodule declarations for `server`,
`client`, `util` (stub files, filled by the extraction tasks). `client`, `util` (stub files, filled by the extraction tasks).
- `src/vendor/server.rs`, `src/vendor/client.rs`, - `src/vendor/server.rs`, `src/vendor/client.rs`,
`src/vendor/util/mod.rs`, `src/vendor/util/stream.rs`, `src/vendor/util/mod.rs`, `src/vendor/util/stream.rs`,
`src/vendor/util/target_addr.rs` — empty stubs. `src/vendor/util/target_addr.rs`: empty stubs.
4. **Bundled license notice** at `src/vendor/LICENSE`: the upstream MIT 4. **Bundled license notice** at `src/vendor/LICENSE`: the upstream MIT
license text plus attribution (fast-socks5 v1.0.0, license text plus attribution (fast-socks5 v1.0.0,
github.com/dizda/fast-socks5), referenced from the vendor module doc github.com/dizda/fast-socks5), referenced from the vendor module doc
(ADR-011's license obligation — upstream has no per-file headers, so (ADR-011's license obligation; upstream has no per-file headers, so
the bundled notice is the obligation's carrier). the bundled notice is the obligation's carrier).
## Acceptance Criteria ## Acceptance Criteria
@@ -64,7 +64,7 @@ Deliverables:
- [x] `cargo clippy --all-targets -- -D warnings` clean - [x] `cargo clippy --all-targets -- -D warnings` clean
- [x] `cargo fmt --check` clean - [x] `cargo fmt --check` clean
- [x] `cargo check --target wasm32-unknown-unknown` clean (the wasm gate - [x] `cargo check --target wasm32-unknown-unknown` clean (the wasm gate
is green from birth — no `mio`/`socket2`/`tokio::net` in the graph) is green from birth, with no `mio`/`socket2`/`tokio::net` in the graph)
- [x] `cargo clippy --target wasm32-unknown-unknown -- -D warnings` clean - [x] `cargo clippy --target wasm32-unknown-unknown -- -D warnings` clean
- [x] `Cargo.toml` has no `alkcall`, no `anyhow`, no `log`, no - [x] `Cargo.toml` has no `alkcall`, no `anyhow`, no `log`, no
`async-trait`, no `tokio-stream`, no `socket2` in `[dependencies]` `async-trait`, no `tokio-stream`, no `socket2` in `[dependencies]`
@@ -75,7 +75,7 @@ Deliverables:
## References ## References
- docs/architecture/vendor.md (the vendor spec) - docs/architecture/vendor.md (the vendor spec)
- docs/architecture/decisions/011-inline-vendor.md (§Amendment — the - docs/architecture/decisions/011-inline-vendor.md §Amendment (the
extraction posture, license obligation, wasm gate) extraction posture, license obligation, wasm gate)
- docs/architecture/decisions/009-feature-gates.md (the feature layout) - docs/architecture/decisions/009-feature-gates.md (the feature layout)
- docs/architecture/overview.md §Dependencies (the tokio subset) - docs/architecture/overview.md §Dependencies (the tokio subset)
@@ -86,39 +86,39 @@ Deliverables:
## Notes ## Notes
- **Cargo.toml** follows the alktunnels posture (the closer sibling — - **Cargo.toml** follows the alktunnels posture (the closer sibling with
same tokio wasm-clean subset incl. `time`, same dev-dep tokio the same tokio wasm-clean subset incl. `time`, same dev-dep tokio
shape): `exclude` mirrors alktunnels (docs/research/ + tasks/ + shape): `exclude` mirrors alktunnels (docs/research/ + tasks/ +
sdd_process.md); keywords/categories/description point at the sdd_process.md); keywords/categories/description point at the
SOCKS5 story; repository is the git.alk.dev remote. `[features]` SOCKS5 story; repository is the git.alk.dev remote. `[features]`
declares `default`/`local`/`composed` empty (ADR-009 §Amendment — declares `default`/`local`/`composed` empty (ADR-009 §Amendment,
populated by their modules' tasks); `[dependencies]` is exactly the to be populated by their modules' tasks); `[dependencies]` is exactly the
task's trio (tokio wasm-clean subset, tracing, thiserror — no task's trio (tokio wasm-clean subset, tracing, thiserror, i.e. no
alkcall, no net-shaped anything). A `[lib]` section pins the crate alkcall and no net-shaped anything). A `[lib]` section pins the crate
name, matching the siblings. name, matching the siblings.
- **lib.rs** — `#![forbid(unsafe_code)]`, crate docs (what the crate - **lib.rs**: `#![forbid(unsafe_code)]`, crate docs (what the crate
is, the never-binds posture, the vendor provenance pointer, the is, the never-binds posture, the vendor provenance pointer, the
overview.md link), `pub mod vendor;`. Nothing else: later tasks add overview.md link), `pub mod vendor;`. Nothing else: later tasks add
their own modules + re-exports. their own modules + re-exports.
- **src/vendor/** skeleton preserves upstream v1.0.0 file names for - **src/vendor/** skeleton preserves upstream v1.0.0 file names for
mechanical per-file diffs: `mod.rs` (provenance module doc + mechanical per-file diffs: `mod.rs` (provenance module doc +
submodule declarations), `server.rs`, `client.rs`, submodule declarations), `server.rs`, `client.rs`,
`util/{mod,stream,target_addr}.rs` — the five files the extraction `util/{mod,stream,target_addr}.rs`: the five files the extraction
tasks fill. Stubs carry one-line module docs naming their filling tasks fill. Stubs carry one-line module docs naming their filling
task and (for the util files) the cut-region note; `util/mod.rs` task and (for the util files) the cut-region note; `util/mod.rs`
pre-declares `stream` + `target_addr` exactly as upstream does. pre-declares `stream` + `target_addr` exactly as upstream does.
`socks4/` is deliberately absent (the cut). `socks4/` is deliberately absent (the cut).
- **src/vendor/LICENSE** — the upstream MIT text verbatim (Copyright - **src/vendor/LICENSE**: the upstream MIT text verbatim (Copyright
(c) 2025 Jonathan Dizdarevic, per `/workspace/fast-socks5/LICENSE`) (c) 2025 Jonathan Dizdarevic, per `/workspace/fast-socks5/LICENSE`)
under a provenance header (v1.0.0, github.com/dizda/fast-socks5, under a provenance header (v1.0.0, github.com/dizda/fast-socks5,
ADR-011 pointer, vendor.md pointer); referenced from the `mod.rs` ADR-011 pointer, vendor.md pointer); referenced from the `mod.rs`
module doc via an intra-doc link. The upstream license has no module doc via an intra-doc link. The upstream license has no
"MIT License" body-text title line — it opens with "MIT License" "MIT License" body-text title line; it opens with "MIT License"
+ copyright; the notice keeps that text verbatim after the header. + copyright, and the notice keeps that text verbatim after the header.
- Verification ran clean: `cargo test` (0 tests, compiles + runs), - Verification ran clean: `cargo test` (0 tests, compiles + runs),
clippy `--all-targets -- -D warnings`, `cargo fmt --check`, clippy `--all-targets -- -D warnings`, `cargo fmt --check`,
`cargo check --target wasm32-unknown-unknown`, and `cargo check --target wasm32-unknown-unknown`, and
`cargo clippy --target wasm32-unknown-unknown -- -D warnings` — `cargo clippy --target wasm32-unknown-unknown -- -D warnings`, so
the wasm gate is green from birth (the dep graph is tokio-wasm- the wasm gate is green from birth (the dep graph is tokio-wasm-
subset + tracing + thiserror only). subset + tracing + thiserror only).
@@ -1,6 +1,6 @@
--- ---
id: implementation/vendor-differential-tests id: implementation/vendor-differential-tests
name: Vendor — the differential RFC edge-case test suite name: Vendor (the differential RFC edge-case test suite)
status: completed status: completed
depends_on: [implementation/vendor-server-typestate, implementation/vendor-client-session] depends_on: [implementation/vendor-server-typestate, implementation/vendor-client-session]
scope: moderate scope: moderate
@@ -11,18 +11,18 @@ level: implementation
## Description ## Description
Build the crate's RFC edge-case test suite for the extracted vendor — Build the crate's RFC edge-case test suite for the extracted vendor,
the differential-testing posture vendor.md pins: "the crate's test per the differential-testing posture vendor.md pins: "the crate's test
suite covers the edge cases the extracted code must keep passing; when suite covers the edge cases the extracted code must keep passing; when
in doubt, diff behavior against the reference checkout." The per-task in doubt, diff behavior against the reference checkout." The per-task
unit tests (wire vocabulary, typestate, client session) cover the unit tests (wire vocabulary, typestate, client session) cover the
happy paths; this task covers the RFC edge cases and the happy paths; this task covers the RFC edge cases and the
extraction-fidelity checks, using `/workspace/fast-socks5` as the extraction-fidelity checks, using `/workspace/fast-socks5` as the
behavioral reference (read-only — tests never depend on it at build behavioral reference (read-only, since tests never depend on it at build
time; the reference is consulted while writing the tests, and the time; the reference is consulted while writing the tests, and the
assertions pin the expected bytes). assertions pin the expected bytes).
Test infrastructure: tokio duplex streams (`tokio::io::duplex`) — Test infrastructure: tokio duplex streams (`tokio::io::duplex`) with
no sockets, no `local` feature, runs in the default wasm-clean crate's no sockets, no `local` feature, runs in the default wasm-clean crate's
test suite (tests are native-only via dev-deps; the crate itself stays test suite (tests are native-only via dev-deps; the crate itself stays
wasm-clean). A first-party test harness drives the vendored typestate wasm-clean). A first-party test harness drives the vendored typestate
@@ -39,8 +39,8 @@ the wire form is the point):
`EmptyUsername`, empty password → `EmptyPassword`, reject path → `EmptyUsername`, empty password → `EmptyPassword`, reject path →
0x01/0xff reply then error). 0x01/0xff reply then error).
2. **Command-read edge cases**: unknown command byte (0x04+ → 2. **Command-read edge cases**: unknown command byte (0x04+ →
`UnknownCommand` → 0x07 reply), reserved-byte tolerance (RSV ≠ 0 — `UnknownCommand` → 0x07 reply), reserved-byte tolerance (RSV ≠ 0;
upstream reads it without checking; pin the upstream behavior), upstream reads it without checking, so pin that upstream behavior),
address-type edge cases (ATYP 0x02/0x05+ → `IncorrectAddressType` address-type edge cases (ATYP 0x02/0x05+ → `IncorrectAddressType`
→ 0x08 reply), domain-length boundary (255-byte domain OK, → 0x08 reply), domain-length boundary (255-byte domain OK,
256+ rejected at encode time by `to_be_bytes`), truncated address 256+ rejected at encode time by `to_be_bytes`), truncated address
@@ -54,11 +54,11 @@ the wire form is the point):
the mapped `ReplyError` (the `from_u8` table), domain-target the mapped `ReplyError` (the `from_u8` table), domain-target
request encoding (ATYP=3, len-prefixed), the UDP-ASSOCIATE request encoding (ATYP=3, len-prefixed), the UDP-ASSOCIATE
zero-target fallback (upstream `request_header`'s zero-target fallback (upstream `request_header`'s
`target_addr: None` branch — pin the 10-byte zero form), `target_addr: None` branch, the 10-byte zero form being pinned),
`skip_auth` behavior (no method negotiation bytes on the wire). `skip_auth` behavior (no method negotiation bytes on the wire).
5. **UDP codec edge cases**: `parse_udp_request` with garbage RSV → 5. **UDP codec edge cases**: `parse_udp_request` with garbage RSV →
`GarbageInReserved`, FRAG ≠ 0 passthrough (the parser returns frag; `GarbageInReserved`, FRAG ≠ 0 passthrough (the parser returns frag,
the *caller* refuses — pin the parser's contract), and the *caller* refuses; pin the parser's contract),
`new_udp_header`/`parse_udp_request` round-trip for v4/v6/domain. `new_udp_header`/`parse_udp_request` round-trip for v4/v6/domain.
6. **`transfer`/`wait_on_tcp` semantics**: `transfer` pumps both 6. **`transfer`/`wait_on_tcp` semantics**: `transfer` pumps both
directions to completion over duplexes (including half-close directions to completion over duplexes (including half-close
@@ -71,14 +71,14 @@ the *same* expectation in-tree, noting the upstream source line in the
test's doc comment where the expectation is non-obvious. Where the test's doc comment where the expectation is non-obvious. Where the
extraction deliberately diverges (the pruned error variants, the extraction deliberately diverges (the pruned error variants, the
published `new_reply`), the test pins the *in-tree* contract and the published `new_reply`), the test pins the *in-tree* contract and the
divergence is already recorded in vendor.md — no new divergence may be divergence is already recorded in vendor.md, so no new divergence may be
introduced by this task. introduced by this task.
## Acceptance Criteria ## Acceptance Criteria
- [x] The six coverage groups above each have at least one test; - [x] The six coverage groups above each have at least one test;
all pass in `cargo test` all pass in `cargo test`
- [x] Tests use only duplex/in-memory streams — no `TcpListener`/ - [x] Tests use only duplex/in-memory streams, with no `TcpListener`/
`UdpSocket`/`tokio::net` in test code (the suite runs in the `UdpSocket`/`tokio::net` in test code (the suite runs in the
default-feature build) default-feature build)
- [x] Every non-obvious expectation carries a doc-comment note naming - [x] Every non-obvious expectation carries a doc-comment note naming
@@ -100,7 +100,7 @@ introduced by this task.
testing guards the RFC edge cases) testing guards the RFC edge cases)
- docs/architecture/decisions/002-protocol-base.md (faithful - docs/architecture/decisions/002-protocol-base.md (faithful
reply-code mapping) reply-code mapping)
- Reference checkout: `/workspace/fast-socks5` (v1.0.0 — behavior - Reference checkout: `/workspace/fast-socks5` (v1.0.0, the behavior
source for the pinned expectations; its `src/lib.rs` test module and source for the pinned expectations; its `src/lib.rs` test module and
`src/server.rs` test module are the upstream coverage to mirror `src/server.rs` test module are the upstream coverage to mirror
where extractable) where extractable)
@@ -109,14 +109,14 @@ introduced by this task.
## Notes ## Notes
- **Suite location**: `tests/vendor-differential.rs` (an integration - **Suite location**: `tests/vendor-differential.rs` (an integration
test — dev-deps only, no crate-surface change). 32 tests over the test, dev-deps only, with no crate-surface change). 32 tests over the
six coverage groups; tokio duplex pairs throughout, no sockets. six coverage groups; tokio duplex pairs throughout, no sockets.
- **Differential method followed**: each non-obvious expectation's - **Differential method followed**: each non-obvious expectation's
doc comment names the upstream file + line it pins (e.g. doc comment names the upstream file + line it pins (e.g.
`src/server.rs:903-914` for method-order preference, `src/server.rs:903-914` for method-order preference,
`src/client.rs:277-285` for the UDP-ASSOCIATE zero fallback, `src/client.rs:277-285` for the UDP-ASSOCIATE zero fallback,
`src/server.rs:1236-1242` for the parser/caller FRAG split). The `src/server.rs:1236-1242` for the parser/caller FRAG split). The
reference checkout was read, not linked — assertions pin bytes. reference checkout was read, not linked, so assertions pin bytes.
- **Client-session private-API edge cases**: two wire behaviors are - **Client-session private-API edge cases**: two wire behaviors are
only reachable through private methods (`request_header`'s only reachable through private methods (`request_header`'s
`target_addr: None` zero fallback; the `target_addr` field reset `target_addr: None` zero fallback; the `target_addr` field reset
@@ -125,22 +125,22 @@ introduced by this task.
`0.0.0.0:0` via the vendored `read_address`); the code-path forms `0.0.0.0:0` via the vendored `read_address`); the code-path forms
of those tests already live in `src/vendor/client.rs`'s in-module of those tests already live in `src/vendor/client.rs`'s in-module
tests (privacy-scoped), written by the client-session task. No new tests (privacy-scoped), written by the client-session task. No new
divergence introduced — the pin notes cross-reference both. divergence introduced; the pin notes cross-reference both.
- **Duplex half-close shape**: `DuplexStream`'s shutdown closes the - **Duplex half-close shape**: `DuplexStream`'s shutdown closes the
whole pipe's write side, so the `transfer` half-close-propagation whole pipe's write side, so the `transfer` half-close-propagation
group is driven with drops (full EOF) rather than group is driven with drops (full EOF) rather than
`shutdown()`-per-direction; the test's doc comment records why. `shutdown()`-per-direction; the test's doc comment records why.
- **First-party-only divergence check**: the only in-tree contracts - **First-party-only divergence check**: the only in-tree contracts
the tests pin beyond upstream behavior are the two recorded the tests pin beyond upstream behavior are the two recorded
carried changes/adjustments (`new_reply` published — group 3; the carried changes/adjustments (`new_reply` published, in group 3; the
typed `MissingTargetAddr` replacing upstream's `anyhow::Error` — typed `MissingTargetAddr` replacing upstream's `anyhow::Error` in
group 4 note). No new divergence introduced. group 4's note). No new divergence introduced.
## Summary ## Summary
Built the vendor's differential RFC edge-case suite per vendor.md Built the vendor's differential RFC edge-case suite per vendor.md
§Maintenance Posture: `tests/vendor-differential.rs`, 32 tests §Maintenance Posture: `tests/vendor-differential.rs`, 32 tests
covering the six groups — negotiation edge cases (empty/zero method covering the six groups: negotiation edge cases (empty/zero method
lists → 0xff, server-order preference, 0x04 greeting refusal, EOF lists → 0xff, server-order preference, 0x04 greeting refusal, EOF
mid-handshake typed error, password sub-protocol empty mid-handshake typed error, password sub-protocol empty
username/password + the 0x01/0xff-then-error reject path); command username/password + the 0x01/0xff-then-error reject path); command
+43 -41
View File
@@ -1,6 +1,6 @@
--- ---
id: implementation/vendor-server-typestate id: implementation/vendor-server-typestate
name: Vendor — the server typestate (Socks5ServerProtocol + carried changes) name: Vendor (the server typestate, Socks5ServerProtocol + carried changes)
status: completed status: completed
depends_on: [implementation/vendor-wire-vocabulary] depends_on: [implementation/vendor-wire-vocabulary]
scope: narrow scope: narrow
@@ -11,8 +11,8 @@ level: implementation
## Description ## Description
Extract the server typestate — fast-socks5's explicit API, the Extract the server typestate, meaning fast-socks5's explicit API and the
producer's conversation driver (ADR-002) — into producer's conversation driver (ADR-002), into
`src/vendor/server.rs`, and apply the two carried changes that touch `src/vendor/server.rs`, and apply the two carried changes that touch
this file (BIND's reply surface + the `new_reply` publication). This this file (BIND's reply surface + the `new_reply` publication). This
is the highest-fidelity-sensitive extraction: the typestate is the is the highest-fidelity-sensitive extraction: the typestate is the
@@ -23,8 +23,8 @@ task group) drives it directly over a channels `BiStream`.
1. `SocksServerError` enum + `to_reply_error` verbatim (the 1. `SocksServerError` enum + `to_reply_error` verbatim (the
server-side wire error vocabulary; keep `UnexpectedUdpControlGarbage` server-side wire error vocabulary; keep `UnexpectedUdpControlGarbage`
— the ASSOCIATE quiet-wait semantics consume it). since the ASSOCIATE quiet-wait semantics consume it).
2. `ErrorContext<T>` trait + its two impls (`err_when`) verbatim — 2. `ErrorContext<T>` trait + its two impls (`err_when`) verbatim, i.e.
the typestate's internal error-context idiom, used pervasively in the typestate's internal error-context idiom, used pervasively in
the extracted region. the extracted region.
3. The typestate core, verbatim: 3. The typestate core, verbatim:
@@ -39,54 +39,56 @@ task group) drives it directly over a channels `BiStream`.
`password_states` module, `PasswordAuthenticationStarted` alias). `password_states` module, `PasswordAuthenticationStarted` alias).
- `auth_method_enums!` macro + `StandardAuthentication` (keep the - `auth_method_enums!` macro + `StandardAuthentication` (keep the
macro `#[macro_export]`-free per the same adjustment as macro `#[macro_export]`-free per the same adjustment as
`read_exact!` — `pub(crate) use` scoping; upstream exports it at `read_exact!` (`pub(crate) use` scoping; upstream exports it at
the crate root). the crate root).
- `negotiate_auth`, `read_command`, `reply_success`, `reply_error`. - `negotiate_auth`, `read_command`, `reply_success`, `reply_error`.
- `transfer` + `wait_on_tcp` (the typestate's own two-pump seam — - `transfer` + `wait_on_tcp` (the typestate's own two-pump seam;
vendor.md's extraction table; `transfer` is the interception-point vendor.md's extraction table says `transfer` is the
shape, `wait_on_tcp` is the ASSOCIATE control-stream reader). interception-point shape and `wait_on_tcp` is the ASSOCIATE
control-stream reader).
- `try_notify!` macro (internal to the extracted region; same - `try_notify!` macro (internal to the extracted region; same
scoping adjustment). scoping adjustment).
4. **Carried change #2 — `new_reply` publication** (vendor.md's 4. **Carried change #2, the `new_reply` publication** (vendor.md's
carried changes; POC #5 ledger ask #3): the reply encoder becomes carried changes; POC #5 ledger ask #3): the reply encoder becomes
public as a `reply(code, addr)`-shaped constructor. Upstream: public as a `reply(code, addr)`-shaped constructor. Upstream:
`fn new_reply(error: &ReplyError, sock_addr: SocketAddr) -> Vec<u8>` `fn new_reply(error: &ReplyError, sock_addr: SocketAddr) -> Vec<u8>`
(private, `server.rs:1380`). In-tree: `pub fn new_reply(...)` (private, `server.rs:1380`). In-tree: `pub fn new_reply(...)`
(or `pub fn reply(code: &ReplyError, addr: SocketAddr) -> Vec<u8>` (or `pub fn reply(code: &ReplyError, addr: SocketAddr) -> Vec<u8>`;
— either name; pin one and use it consistently) so the BIND either name; pin one and use it consistently) so the BIND
reply#2 writer and the consumer's reply readers don't re-encode reply#2 writer and the consumer's reply readers don't re-encode
the 10/22-byte reply form. The vendored `reply_success`/`reply_error` the 10/22-byte reply form. The vendored `reply_success`/`reply_error`
call it; nothing else changes. call it; nothing else changes.
**What is NOT extracted** (the cut — POC #4's gate map + the recipe's **What is NOT extracted** (the cut, being POC #4's gate map plus the recipe's
two additions; vendor.md's cut table row 1): two additions; vendor.md's cut table row 1):
- `run_tcp_proxy`, `run_udp_proxy`/`run_udp_proxy_custom`, - `run_tcp_proxy`, `run_udp_proxy`/`run_udp_proxy_custom`,
`udp_bind_random_port`, `handle_udp_*`/`transfer_udp` (all `udp_bind_random_port`, `handle_udp_*`/`transfer_udp` (all
kernel-net; the producer drives the typestate directly — POC #2's kernel-net; the producer drives the typestate directly, being POC #2's
shape — and the local backends are first-party tokio code, ADR-008). shape, and the local backends are first-party tokio code, ADR-008).
- The legacy `Socks5Server`/`Incoming`/`Socks5Socket` (struct + impls, - The legacy `Socks5Server`/`Incoming`/`Socks5Socket` (struct + impls,
including the `AsyncRead`/`AsyncWrite` impls and the `Unpin` impl — including the `AsyncRead`/`AsyncWrite` impls and the `Unpin` impl;
they exist only for the legacy API). they exist only for the legacy API).
- `DnsResolveHelper` trait + impl (net-gated upstream; the wrapper - `DnsResolveHelper` trait + impl (net-gated upstream; the wrapper
resolves nothing — domain targets pass through unresolved, ADR-012). resolves nothing, since domain targets pass through unresolved,
ADR-012).
- The legacy `Config<A>` + `Authentication` trait + impls - The legacy `Config<A>` + `Authentication` trait + impls
(`SimpleUserPassword`/`DenyAuthentication`/`AcceptAuthentication`, (`SimpleUserPassword`/`DenyAuthentication`/`AcceptAuthentication`,
`authenticate_callback`) — the recipe's addition: ungated upstream `authenticate_callback`) which is the recipe's addition: ungated upstream
but consumed only by the cut legacy region. With it go the but consumed only by the cut legacy region. With it go the
`#[allow(deprecated)]` attributes on the cut impls. `#[allow(deprecated)]` attributes on the cut impls.
- The net-only test module (`test_bind` — binds a real listener). - The net-only test module (`test_bind`, which binds a real listener).
**Extraction-time adjustments** (mechanical): **Extraction-time adjustments** (mechanical):
- Drop `anyhow::Context` imports and `log` calls (`trace!`/`debug!`/ - Drop `anyhow::Context` imports and `log` calls (`trace!`/`debug!`/
`error!`/`warn!` in the extracted region — drop or re-point at `error!`/`warn!` in the extracted region; drop or re-point at
`tracing`; the typestate's traces are per-field debug logs, drop `tracing`. The typestate's traces are per-field debug logs, so drop
them; keep behavior identical). them, keeping behavior identical).
- `reply_error`'s `"0.0.0.0:0".parse().unwrap()` (upstream - `reply_error`'s `"0.0.0.0:0".parse().unwrap()` (upstream
`server.rs:945`): replace the `unwrap` with a `const` parsed at `server.rs:945`): replace the `unwrap` with a `const` parsed at
compile time or a documented-infallible construction (AGENTS.md compile time or a documented-infallible construction (AGENTS.md
convention 2 — no `unwrap` outside tests; the parse is statically convention 2: no `unwrap` outside tests, and the parse is statically
infallible, so a `const`/`SocketAddr::new` form is the faithful infallible, so a `const`/`SocketAddr::new` form is the faithful
translation). translation).
- `Socks5Command::from_u8` returning `Option` + `ok_or` stays (the - `Socks5Command::from_u8` returning `Option` + `ok_or` stays (the
@@ -94,11 +96,11 @@ two additions; vendor.md's cut table row 1):
`CommandNotSupported`, RFC-faithful). `CommandNotSupported`, RFC-faithful).
- Keep `#[allow(async_fn_in_trait)]` only where upstream had it and - Keep `#[allow(async_fn_in_trait)]` only where upstream had it and
the extracted region still needs it (the `DnsResolveHelper` trait is the extracted region still needs it (the `DnsResolveHelper` trait is
cut; check whether any remaining trait needs the attribute — cut; check whether any remaining trait needs the attribute, though
`AuthMethod` is not async, so likely none). `AuthMethod` is not async, so likely none).
**The BIND note** (why carried change #1 does not appear here): BIND's **The BIND note** (why carried change #1 does not appear here): BIND's
driving is wrapper-side code (POC #5 — "zero upstream ask for BIND driving is wrapper-side code (POC #5, which found "zero upstream ask for BIND
itself"): the typestate's `read_command()` already returns itself"): the typestate's `read_command()` already returns
`TCPBind`, `reply_success(sock_addr)` is reply#1's carrier, and the `TCPBind`, `reply_success(sock_addr)` is reply#1's carrier, and the
reply#2 writer uses the published `new_reply`. The vendor carries the reply#2 writer uses the published `new_reply`. The vendor carries the
@@ -117,18 +119,18 @@ surface BIND needs is present and public.
`Socks5Socket`/`DnsResolveHelper`/`Config`/`Authentication`/ `Socks5Socket`/`DnsResolveHelper`/`Config`/`Authentication`/
`authenticate_callback` anywhere under `src/vendor/` `authenticate_callback` anywhere under `src/vendor/`
(only doc-comment provenance mentions, matching `mod.rs`'s (only doc-comment provenance mentions, matching `mod.rs`'s
precedent — no definitions) precedent, and no definitions)
- [x] No `anyhow`, `log`, `tokio::net`, `socket2`, `tokio-stream`, - [x] No `anyhow`, `log`, `tokio::net`, `socket2`, `tokio-stream`,
`async-trait` anywhere under `src/vendor/` `async-trait` anywhere under `src/vendor/`
- [x] `new_reply` (or `reply`) is `pub` and produces byte-identical - [x] `new_reply` (or `reply`) is `pub` and produces byte-identical
output to upstream's private encoder (10 bytes v4 / 22 bytes v6, output to upstream's private encoder (10 bytes v4 / 22 bytes v6,
VER/REP/RSV/ATYP/addr/port) — unit-tested (kept upstream's VER/REP/RSV/ATYP/addr/port), unit-tested (kept upstream's
`new_reply` name; the task allowed either) `new_reply` name; the task allowed either)
- [x] `reply_error` contains no `unwrap`/`expect` (convention 2) - [x] `reply_error` contains no `unwrap`/`expect` (convention 2)
- [x] `accept_no_auth` over an in-memory duplex completes a full - [x] `accept_no_auth` over an in-memory duplex completes a full
negotiation + `read_command` + `reply_success`/`reply_error` negotiation + `read_command` + `reply_success`/`reply_error`
round-trip against a first-party test client (tokio duplex round-trip against a first-party test client (tokio duplex
streams — no sockets), covering: version mismatch refusal, streams with no sockets), covering: version mismatch refusal,
no acceptable method (0xff reply), unknown command (0x07 reply), no acceptable method (0xff reply), unknown command (0x07 reply),
success reply form, error reply form success reply form, error reply form
- [x] `cargo clippy --all-targets -- -D warnings` and - [x] `cargo clippy --all-targets -- -D warnings` and
@@ -152,9 +154,9 @@ surface BIND needs is present and public.
## Notes ## Notes
- **Verbatim-code lint posture**: upstream style trips two - **Verbatim-code lint posture**: upstream style trips two
newer-clippy lints in the copied region — newer-clippy lints in the copied region:
`clippy::wrong_self_convention` (`AuthMethod::new(self, inner)` `clippy::wrong_self_convention` (`AuthMethod::new(self, inner)`,
— the typestate's method-initializer idiom) and the typestate's method-initializer idiom) and
`clippy::doc_lazy_continuation` (upstream doc-comment style). `clippy::doc_lazy_continuation` (upstream doc-comment style).
`wrong_self_convention` is allowed at module level with rationale `wrong_self_convention` is allowed at module level with rationale
(churning vendored code would break the mechanical per-file diff (churning vendored code would break the mechanical per-file diff
@@ -170,32 +172,32 @@ surface BIND needs is present and public.
- **`try_notify!`'s `error!` call dropped**: the macro's failure - **`try_notify!`'s `error!` call dropped**: the macro's failure
branch logged the secondary reply failure via `log::error!`; branch logged the secondary reply failure via `log::error!`;
dropped per the adjustments. The branch still awaits dropped per the adjustments. The branch still awaits
`reply_error` (behavior preserved — the error reply still gets a `reply_error` (behavior preserved: the error reply still gets a
best-effort attempt; its failure is now silently ignored, which best-effort attempt; its failure is now silently ignored, which
matches the dropped log's only role). matches the dropped log's only role).
- **`transfer`'s result handling**: upstream logs the - **`transfer`'s result handling**: upstream logs the
`copy_bidirectional` outcome (`debug!`/`error!`); in-tree the `copy_bidirectional` outcome (`debug!`/`error!`); in-tree the
result is explicitly discarded (`let _ =`). The function's result is explicitly discarded (`let _ =`). The function's
contract (run both pumps to completion, absorb the outcome) is contract (run both pumps to completion, absorb the outcome) is
unchanged; the crate's data plane is `pump_bidi` (ADR-050) — unchanged; the crate's data plane is `pump_bidi` (ADR-050), and
`transfer` rides for fidelity as the interception-point shape. `transfer` rides for fidelity as the interception-point shape.
- **`reply_error`'s unwrap replacement**: upstream parses - **`reply_error`'s unwrap replacement**: upstream parses
`"0.0.0.0:0"` at runtime (infallible but `unwrap`); `"0.0.0.0:0"` at runtime (infallible but `unwrap`);
in-tree it is a `const V4_UNSPECIFIED_ZERO: SocketAddr` built from in-tree it is a `const V4_UNSPECIFIED_ZERO: SocketAddr` built from
`Ipv4Addr::UNSPECIFIED` — statically infallible, byte-identical `Ipv4Addr::UNSPECIFIED`, statically infallible and byte-identical
output (the success-reply form test pins the error-reply output (the success-reply form test pins the error-reply
BND.ADDR as `0.0.0.0:0`). BND.ADDR as `0.0.0.0:0`).
- **`auth_method_enums!` scoping**: converted from `#[macro_export]` - **`auth_method_enums!` scoping**: converted from `#[macro_export]`
to `macro_rules!` + `pub(crate) use` (same adjustment as to `macro_rules!` + `pub(crate) use` (same adjustment as
`read_exact!`). As with `ready!` in stream.rs, the re-export has no `read_exact!`). As with `ready!` in stream.rs, the re-export has no
in-crate consumer yet (the macro's only use is in this file), so in-crate consumer yet (the macro's only use is in this file), so
it carries `#[allow(unused_imports)]` with the same rationale — it carries `#[allow(unused_imports)]` with the same rationale,
kept verbatim so the util/vendor diff stays complete for the kept verbatim so the util/vendor diff stays complete for the
client-session task, which may consume it. client-session task, which may consume it.
- **Tests are first-party**: upstream's `server.rs` test module is - **Tests are first-party**: upstream's `server.rs` test module is
the net-only `test_bind` (cut); the test module here is written the net-only `test_bind` (cut); the test module here is written
fresh against the crate's test layout — tokio duplex streams, no fresh against the crate's test layout (tokio duplex streams, no
sockets, no `env_logger`. The round-trip suite covers the sockets, no `env_logger`). The round-trip suite covers the
acceptance list: version mismatch, 0xff method refusal (with the acceptance list: version mismatch, 0xff method refusal (with the
2-byte wire reply pinned), unknown command (0x07 reply + the 2-byte wire reply pinned), unknown command (0x07 reply + the
`try_notify!` error-reply path), success/error reply forms `try_notify!` error-reply path), success/error reply forms
@@ -203,7 +205,7 @@ surface BIND needs is present and public.
`new_reply` byte-identity is pinned v4 (10 bytes) + v6 (22 bytes) `new_reply` byte-identity is pinned v4 (10 bytes) + v6 (22 bytes)
+ the full reply-code table (0x00–0x08, TtlExpired/ConnectionTimeout + the full reply-code table (0x00–0x08, TtlExpired/ConnectionTimeout
both mapping to 0x06). both mapping to 0x06).
- **`Socks5Command::from_u8` + `ok_or` stays** as upstream — the - **`Socks5Command::from_u8` + `ok_or` stays** as upstream: the
unknown-command path maps through unknown-command path maps through
`SocksServerError::UnknownCommand` → `CommandNotSupported` (pinned `SocksServerError::UnknownCommand` → `CommandNotSupported` (pinned
by the unknown-command test's 0x07 reply byte). by the unknown-command test's 0x07 reply byte).
@@ -215,7 +217,7 @@ surface BIND needs is present and public.
directly; the data plane is `pump_bidi`). They ride the extraction directly; the data plane is `pump_bidi`). They ride the extraction
per vendor.md's extraction table; if dead-code warnings had per vendor.md's extraction table; if dead-code warnings had
appeared they would have been handled like the vocabulary task's appeared they would have been handled like the vocabulary task's
`ready!` — none did (pub items in a lib crate are reachable). `ready!` behavior; none did (pub items in a lib crate are reachable).
## Summary ## Summary
@@ -233,13 +235,13 @@ alias), the `auth_method_enums!` macro (de-exported to
`negotiate_auth`/`read_command`/`reply_success`/`reply_error`, `negotiate_auth`/`read_command`/`reply_success`/`reply_error`,
`transfer`/`wait_on_tcp`, the `try_notify!` macro, `ErrorContext<T>` `transfer`/`wait_on_tcp`, the `try_notify!` macro, `ErrorContext<T>`
+ impls, and `SocksServerError` (already landed verbatim by the + impls, and `SocksServerError` (already landed verbatim by the
wire-vocabulary task's early `#[from]` need — now joined by the rest wire-vocabulary task's early `#[from]` need, now joined by the rest
of the inventory). The cut regions (the legacy of the inventory). The cut regions (the legacy
`Socks5Server`/`Incoming`/`Socks5Socket`, `Config<A>` + `Socks5Server`/`Incoming`/`Socks5Socket`, `Config<A>` +
`Authentication` + the example auth impls + `authenticate_callback`, `Authentication` + the example auth impls + `authenticate_callback`,
`DnsResolveHelper`, the `run_*` kernel-net handlers + `DnsResolveHelper`, the `run_*` kernel-net handlers +
`udp_bind_random_port` + `handle_udp_*`/`transfer_udp`, the net-only `udp_bind_random_port` + `handle_udp_*`/`transfer_udp`, the net-only
`test_bind`) were not copied — verified by sweep. Carried change #2 `test_bind`) were not copied, as verified by sweep. Carried change #2
landed: `new_reply` is `pub` (upstream name kept), with byte-identity landed: `new_reply` is `pub` (upstream name kept), with byte-identity
tests (v4 10-byte / v6 22-byte forms + the full reply-code table). tests (v4 10-byte / v6 22-byte forms + the full reply-code table).
Extraction-time adjustments applied: `log` calls dropped (with the Extraction-time adjustments applied: `log` calls dropped (with the
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more