diff --git a/.opencode/agents/architect.md b/.opencode/agents/architect.md index 836546b..bfdbeef 100644 --- a/.opencode/agents/architect.md +++ b/.opencode/agents/architect.md @@ -37,10 +37,10 @@ docs/architecture/ The README is the entry point. It contains: -1. **Current State** — what phase the project is in, what's implemented -2. **Architecture Documents** — table linking to each spec doc with status -3. **ADR Table** — every decision with number, title, and status -4. **Open Questions** — link to `open-questions.md` +1. **Current State**: what phase the project is in, what's implemented +2. **Architecture Documents**: table linking to each spec doc with status +3. **ADR Table**: every decision with number, title, and status +4. **Open Questions**: link to `open-questions.md` ### Spec Documents @@ -83,7 +83,7 @@ Accepted | Proposed | Deprecated | Superseded (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 mark the old one Superseded. @@ -97,12 +97,12 @@ don't need ADRs. `open-questions.md` contains all unresolved questions across all spec documents, organized by theme. Each question has: -- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference -- **Origin** — which spec doc(s) the question appeared in -- **Status** — open, resolved, partially resolved -- **Priority** — high, medium, low -- **Resolution** — when resolved, what was decided and which ADR addresses it -- **Cross-references** — related OQs and ADRs +- **OQ-ID** (OQ-01, OQ-02, ...): stable reference +- **Origin**: which spec doc(s) the question appeared in +- **Status**: open, resolved, partially resolved +- **Priority**: high, medium, low +- **Resolution**: when resolved, what was decided and which ADR addresses it +- **Cross-references**: related OQs and ADRs 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 @@ -136,7 +136,7 @@ Before writing architecture: - Understand the problem domain - Identify constraints and quality attributes - 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 ### 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 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 open questions tracked centrally. @@ -180,7 +180,7 @@ Before requesting external review: blocking condition (for `deferred(scope)`) or investigation target (for `deferred(unclear)`) isn't a *prerequisite* of the thing you're 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 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 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 @@ -216,12 +216,12 @@ task( 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 terms, circular deferrals -- **Warning**: Should fix — missing cross-references, documents approaching +- **Warning**: Should fix; missing cross-references, documents approaching split threshold -- **Suggestion**: Consider — minor clarity improvements +- **Suggestion**: Consider minor clarity improvements 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" 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 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 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 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 decision-making. See ADR-009 §"What this framework is NOT" for the full rationale. @@ -281,17 +281,17 @@ Architecture decisions are the architect's, regardless of door type. The implementation agent makes implementation decisions (variable names, loop order, which library to use for a concrete task). If a 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 -1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs — extract to ADRs -2. **Inline open questions**: Scattered per-doc "Open Questions" sections — +1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs; extract to ADRs +2. **Inline open questions**: Scattered per-doc "Open Questions" sections; 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 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 6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?", write an ADR @@ -310,7 +310,7 @@ reasoning to see its own circular hedges; a fresh context catches them. ## Safe Exit: Deferred Decisions 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 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 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 -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. 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 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 -types, existing patterns) but the composition — how they fit together -into a coherent shape — isn't clear yet. Resolution is *investigation*, +types, existing patterns) but the composition (how they fit together +into a coherent shape) isn't clear yet. Resolution is *investigation*, not waiting: work through example use cases, maybe build a POC, maybe 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 type) is still unclear. Forcing a decision in that state produces a 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 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 POC), not *waiting* (for a spec or use case to arrive) - 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 ### 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` (implies it's decided). 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 exists" or "investigation: work through 2+ example outbound-dial use cases (hub→worker, worker→hub) to see how verifier-selection + 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 workers" not "blocks the hub crate." This is the triage signal that makes the deferral's urgency visible. If the impact is significant, the deferral needs to be addressed soon; if it's a future feature, 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 next architecture revision. @@ -396,10 +396,10 @@ A decision should be `deferred(unclear)` when: - **Status**: deferred(scope) | deferred(unclear) - **Door type**: - **Priority**: -- **Impacts**: +- **Impacts**: - **Blocked on**: (for deferred(scope)) **Investigation**: (for deferred(unclear)) -- **Resolution**: Not yet decidable. - **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 with an escape hatch" is hedging. - 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)`. - 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 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. ## When to Redirect diff --git a/.opencode/agents/architecture-reviewer.md b/.opencode/agents/architecture-reviewer.md index 0fe1017..8f71032 100644 --- a/.opencode/agents/architecture-reviewer.md +++ b/.opencode/agents/architecture-reviewer.md @@ -84,7 +84,7 @@ Check coverage of: #### 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 fresh context can see the whole picture and spot reasoning that folds 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 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 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. -- "additive, not blocking" — if it's not decided, don't claim it is. -- "two-way door — can be changed later if needed" — door type - classifies reversal cost, not whether a decision is made. -- "not a v1 blocker" — if it's not decided, it's deferred. Say what +- "additive, not blocking": if it's not decided, don't claim it is. +- "two-way door, can be changed later if needed" (door type + classifies reversal cost, not whether a decision is made). +- "not a v1 blocker": if it's not decided, it's deferred. Say what 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. - 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 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 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 blocker needs it. @@ -136,17 +136,17 @@ Check each deferred OQ: or do the pieces exist but the shape wasn't synthesized (`deferred(unclear)` is correct), or do the pieces *and* the shape 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 -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)` only if the information genuinely is missing. **3. Legitimate deferral.** A deferred OQ where the information genuinely doesn't exist yet (`deferred(scope)`) or the pieces exist 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 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 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. - Is it specific? "Blocks the first hub deployment because the hub dials workers" is useful. "Blocks the hub crate" is boilerplate. - 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. Flag missing impacts fields as **warning**, vague ones as @@ -259,5 +259,5 @@ section 2" - Critical issues must block stabilization - The Decision Quality (F) category is often the highest-value check on 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. diff --git a/.opencode/agents/code-reviewer.md b/.opencode/agents/code-reviewer.md index dfe3129..89da9b8 100644 --- a/.opencode/agents/code-reviewer.md +++ b/.opencode/agents/code-reviewer.md @@ -21,7 +21,7 @@ You are a subagent - you are invoked by the Coordinator or as a review task. ## Working in Worktrees 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. ```text @@ -111,9 +111,9 @@ cargo fmt --check # Format check For this project, also verify: - 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 -- Feature flags are used correctly (`tls`, `iroh`, `acme`) — base crate compiles +- Feature flags are used correctly (`tls`, `iroh`, `acme`); the base crate compiles lean - Public API is well-documented with `///` doc comments where appropriate - Module structure follows Rust conventions (`mod.rs`, `lib.rs`) diff --git a/.opencode/agents/coordinator.md b/.opencode/agents/coordinator.md index 2327646..02d32d4 100644 --- a/.opencode/agents/coordinator.md +++ b/.opencode/agents/coordinator.md @@ -23,7 +23,7 @@ You manage the execution of decomposed task graphs: ## The `worktree` Tool (via @alkimiadev/open-coordinator) 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. ### Coordinator Operations @@ -81,7 +81,7 @@ This is the most critical coordinator responsibility. Follow it exactly: If merge conflicts occur: - **Source code conflicts between parallel tasks** that modify the same file: 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. - **Doc conflicts**: Read both sides and keep the most recent/complete 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 worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/", 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 dirty worktree, add `force: true`. @@ -132,13 +132,13 @@ in this order: latest main) 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 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 -ignore it. +situation and is exiting. This is the Safe Exit protocol (it's important, don't +ignore it). 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 @@ -162,7 +162,7 @@ ignore it. ``` 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 - Ambiguous architecture? Ask the user to clarify - Scope too large? Decompose into smaller tasks @@ -179,14 +179,14 @@ ignore it. The `prompt` parameter supports `{{task}}` template substitution. Use it, but also include: -1. **Task identification** — How to find their task file in `tasks/` -2. **Merge from main** — Tell them to +1. **Task identification**: How to find their task file in `tasks/` +2. **Merge from main**: Tell them to `git fetch origin && git merge origin/main --no-edit` before starting, since main may have advanced since their worktree was created -3. **Key references** — Which source files and architecture docs to read -4. **Project constraints** — Important rules from the repo (no comments, +3. **Key references**: Which source files and architecture docs to read +4. **Project constraints**: Important rules from the repo (no comments, 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: @@ -200,7 +200,7 @@ Your task: {{task}} 3. Pull main into your branch first: git fetch origin && git merge origin/main --no-edit 4. Implement the changes, following all acceptance criteria. 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) 8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. ", 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. 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. 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." }}) -# Review tasks — often handle yourself +# Review tasks: often handle yourself # If level: review, verify the acceptance criteria against the codebase # 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 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. Check `worktree({action: "sessions"})` when: @@ -312,7 +312,7 @@ Use these when: 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 -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. 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 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. ### Why Agents Don't Commit Task Files @@ -400,12 +400,12 @@ When an agent reports blocked or an anomaly fires: what happened 2. Send guidance via `worktree({action: "message", ...})` if you can help 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 ### 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 of changes, validate, and commit. Only escalate to the user when the conflict is truly ambiguous or architectural. @@ -418,7 +418,7 @@ After merging and pushing: ```text worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/", 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. Don't let stale branches accumulate. diff --git a/.opencode/agents/implementation-specialist.md b/.opencode/agents/implementation-specialist.md index 1a3bb64..479eacf 100644 --- a/.opencode/agents/implementation-specialist.md +++ b/.opencode/agents/implementation-specialist.md @@ -10,7 +10,7 @@ graph. ## Your Environment **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):** @@ -60,14 +60,14 @@ auto-injects `workdir` for bash commands when the session is mapped to a worktree. This means: ```bash -# ✅ CORRECT — workdir is auto-injected +# ✅ CORRECT: workdir is auto-injected cargo test -# ✅ ALSO CORRECT — explicit workdir still works +# ✅ ALSO CORRECT: explicit workdir still works 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. ## Workflow @@ -130,7 +130,7 @@ Check each acceptance criterion in the task file. ### 5. Commit and Notify ```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 commit -m "feat(): " git push origin $(git branch --show-current) @@ -178,7 +178,7 @@ When task becomes untendable: worktree({action: "notify", args: {message: "Blocked on : ", level: "blocking"}}) ``` 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 5. **Exit** - coordinator handles escalation @@ -194,13 +194,13 @@ If NOT in worktree: 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 -- `memory({tool: "messages", args: {sessionId: "..."}})` — review previous +- `memory({tool: "messages", args: {sessionId: "..."}})`: review previous 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 -- `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% 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: -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. -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 `RwLock`/`Mutex`, use `unwrap_or_else(|e| e.into_inner())` so a panic in one 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 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 decrypted credentials. Outbound credentials flow through `Capabilities` injected at the assembly layer → `HandlerRegistration.capabilities` → `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 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 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 (`[channel_id:u32 BE][length:u32 BE][payload]`) are one-way doors. New event 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 side," not "server"/"client." -9. **Vendored core types** — `Connection`, `ProtocolHandler`, `BiStream`, +9. **Vendored core types**: `Connection`, `ProtocolHandler`, `BiStream`, `BidiStreamSource`, `AuthContext`, `IdentityProvider`, `Identity`, `AuthToken`, `Capabilities`, `OwnershipProvider`, `HandlerError`, `StreamError` live in this crate. Do not add a separate `alkcore` dependency. 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 (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 readers/writers/validators; future codegen derives language-specific implementations. Trivial or hot-path formats (chunk header, tty framing) 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 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 on). Verify both `cargo test` (default) and `cargo test --all-features` pass 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`; `continue-running` opt-in. The composing handler decides the child's policy, 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, 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. -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. ## Key Principles diff --git a/.opencode/agents/poc-specialist.md b/.opencode/agents/poc-specialist.md index f5fae5f..25b3efa 100644 --- a/.opencode/agents/poc-specialist.md +++ b/.opencode/agents/poc-specialist.md @@ -10,10 +10,10 @@ approaches. ## Your Environment **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. -- 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/` - 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: ```bash -# ✅ CORRECT — workdir is auto-injected +# ✅ CORRECT: workdir is auto-injected 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. ## When You Are Spawned diff --git a/AGENTS.md b/AGENTS.md index 6a1dce0..b7158cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ The workflow: 1. Make the change 2. Verify: `cargo test`, `cargo clippy --all-targets -- -D warnings`, `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 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 @@ -25,20 +25,20 @@ The workflow: 5. `git push origin main` 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 it kept) - 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 - 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, those signatures are wire-stable contracts) - You'd be force-pushing, amending a published commit, creating an empty commit, or skipping 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. Git identity is preconfigured (`glm-5.3-flash `). Do @@ -46,13 +46,13 @@ not change `git config`, skip hooks, or use `git commit -i`. ## 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 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`/ 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 resource), not on a kernel socket. A local bind is one optional 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 or when a non-obvious safety/correctness constraint would otherwise 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"). -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 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`, use `unwrap_or_else(|e| e.into_inner())` so a panic in one operation 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 types are all async. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for lifecycle correlation. Any non-wasm backend module (local TCP listener for a "real" `socks5://` endpoint) lives behind a feature 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 the alktty/alktunnels precedent. Use the wasm-clean tokio subset - (`rt`, `sync`, `io-util`, `macros`, `time`) — **do NOT use `features - = ["full"]`** — and keep socket/platform I/O feature-gated. Note: + (`rt`, `sync`, `io-util`, `macros`, `time`); **do NOT use `features + = ["full"]`**; and keep socket/platform I/O feature-gated. Note: `fast-socks5` uses `tokio::net` and `socket2` unconditionally as 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 - subset was extracted in-tree — the kernel-net handlers are not - copied — so the RFC 1928 core (`src/rfc1928/`) is wasm-clean by + subset was extracted in-tree (the kernel-net handlers are not + copied), so the RFC 1928 core (`src/rfc1928/`) is wasm-clean by construction with no `net`-shaped feature; socket I/O rides `local` alone. Run the wasm check whenever a non-backend 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 params that carry it. Those params are a one-way door once consumers exist. Follow the alktunnels precedent: params are a self-contained - JSON object identifying the produced resource (alkcall ADR-039 — - `params` is ALPN-specific, interpreted by the open handler). Any + JSON object identifying the produced resource (alkcall ADR-039, + whose `params` is ALPN-specific, interpreted by the open handler). Any params-format ADR must be written before the first consumer exists; after that, changes are additive-only. -6. **Producer/consumer, not server/client** — both sides of a channels - connection can initiate. A producer exposes the SOCKS5 service +6. **Producer/consumer, not server/client**, because both sides of a + channels connection can initiate. A producer exposes the SOCKS5 service (registers openable channels via `ChannelCore::register_openable`); a 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 serves). Avoid "server" and "client" framing in docs and API names; 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 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 socket, a channels `BiStream`, a unix socket, or an in-process pipe. `fast-socks5`'s explicit server API is generic over `T: AsyncRead + AsyncWrite + Unpin` (`Socks5ServerProtocol`, `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 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 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 direction). Each pump MUST shut down the opposite sink when it completes; `tokio::try_join!` alone deadlocks. The helper is pinned - upstream as alkcall `channels::pump_bidi` (ADR-050, review 007 R-03) - — 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 + 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 + drops all per-channel senders on transport EOF, so rely on that for 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; 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 conversation is carried inside a channels data channel, and target 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 real listener (a feature-gated local backend), and the consumer may 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 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 question (OQ-SK-03). Binding decisions belong to the caller (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 - 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 itself is ~4 billion), monotonic channel IDs, and the zero-length-sentinel EOF convention are alkcall channels invariants (see alkcall ADR-040/041/034). The crate consumes them via `ChannelCore`/`ChannelClient`; do not build 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. -11. **Vendored core types come from alkcall** — `Connection`, +11. **Vendored core types come from alkcall**: `Connection`, `ProtocolHandler`, `BiStream`, `BidiStreamSource`, `AuthContext`, `Identity`, `IdentityProvider`, `AccessControl`, `OwnershipProvider`, `HandlerError`, `StreamError` come from `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 rather than working around them. Pin `alkcall = "0.10.1"` and bump deliberately. The establishment surface (alkcall ADR-049 + @@ -187,21 +187,21 @@ implementation agents. use `register_openable_with_establisher` so a refused/auth-failed session is a typed `channel:open_failed` call error, never a phantom channel; the establisher returns the negotiated handle via - `Establishment::new(plan)` (typed-opaque `ChannelPlan` — payloads - 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 + `Establishment::new(plan)` (typed-opaque `ChannelPlan`, whose payloads + must be `Send + Sync`; 0.8.0 adds the optional reply projection, + `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: - 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 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 wires `AccessControl` into the operation spec (the registry runs the ACL before the open handler). Scope-gate SOCKS5 opens (e.g. `SOCKS5_OPEN_SCOPE`, shape following alktty's `TTY_OPEN_SCOPE`) and 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 any) are a producer-side policy question for Phase 1 (OQ-SK-05). 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 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 deps unless the feature is on). Verify both `cargo test` (default) 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 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 expected shape (pending Phase 1 pinning): the SOCKS5 protocol 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 speaks RFC 1928 through them), and feature-gated backend modules (the optional local-bind listener). Backend modules are feature-gated and never imported from the protocol/adapter/client 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). 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 in-tree.** 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 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` - 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, 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 inline-vendor extraction; extraction pinned 2026-09-21, ADR-011 §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 lint-allow posture, no verbatim preservation). The extraction 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 checkout, adapt it to the conventions, and let the differential 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 testing of RFC edge cases. Provenance is permanent: the module docs and the bundled license notice (`src/rfc1928/LICENSE`) 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 @@ -282,8 +300,8 @@ cargo publish --dry-run --allow-dirty # before a release ### Release steps (after the version bump + verification) 1. Commit the release (bump + CHANGELOG/README/AGENTS pins). -2. Tag it: `git tag -a vX.Y.Z -m "release: vX.Y.Z — "` - — **annotated**, always (v0.1.0/v0.2.0 are lightweight; annotated +2. Tag it: `git tag -a vX.Y.Z -m "release: vX.Y.Z ()"` + **annotated**, always (v0.1.0/v0.2.0 are lightweight; annotated since the v0.3.0 release, 2026-10-19). 3. `git push origin main && git push origin vX.Y.Z`. 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 fuzz targets run, on stable, without nightly. Campaigns (nightly, 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). 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 an extraction with no kernel-net code (ADR-011 §Amendment), so no dependency-graph failure is expected. ## 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 (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; `docs/architecture/` does not exist yet). - 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. Its Phase 0 findings (`docs/research/phase-0-findings.md`) settled the hub-owns-the-connection model, the two-pump shape, 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, 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 inversion point, wasm-clean default crate, and feature-gated local 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 consumes `alkcall::core` types and the channels `ChannelCore`/`ChannelClient`/`register_openable_with_establisher` surface, same as alktty/alktunnels. 0.8.0 adds the establisher reply projection (`with_reply_field`, ADR-049 amendment 3), 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. - - **alktunnels** — v0.2.0 (ADRs 007/008): the direct op - (`channels/tunnel/direct` — dynamic-target egress; OQ-SK-01 + - **alktunnels** (v0.2.0, ADRs 007/008): the direct op + (`channels/tunnel/direct`, dynamic-target egress; OQ-SK-01 Option A's vehicle), the bind-first establisher + forwarded op (the composed BIND substrate; OQ-SK-08 variant 2), and `TunnelListener`. - - **fast-socks5** — `/workspace/fast-socks5` (v1.0.0, MIT, - third-party upstream — NOT ours, never will be). The SOCKS5 + - **fast-socks5** (`/workspace/fast-socks5`, v1.0.0, MIT, + third-party upstream, NOT ours and never will be). The SOCKS5 implementation the RFC 1928 core forked: explicit typestate server API (`Socks5ServerProtocol`), generic over `T: AsyncRead + AsyncWrite + Unpin`; `run_tcp_proxy` / @@ -346,48 +364,48 @@ dependency-graph failure is expected. and `Socks5Datagram` (UDP associate). The posture is the **targeted fork** (OQ-SK-04 resolved 2026-09-20 as the 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 kernel-net code and no `net` feature; BIND and the `new_reply` publication are the two carried changes; the reference checkout 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` - (ADR-090): the client-side example need — `Socks5UdpSocket` + (ADR-090), the client-side example: `Socks5UdpSocket` implements `quinn::AsyncUdpSocket` so QUIC rides SOCKS5 UDP ASSOCIATE. Validated by the quinn-proxy POC (`/workspace/@alkdev/alknet/docs/research/quinn-quic-proxy/ findings.md`). The open question for this crate's client story: the 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; see OQ-SK-06). - Key upstream ADRs that inform this crate's design (alknet numbers; alkcall's ports live in `/workspace/@alkdev/alkcall/docs/architecture/ 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 rides inside the channel's `BiStream` - - alknet ADR-078 / alkcall ADR-050 — two-pump shutdown-on-completion; - the helper is pinned upstream as `alkcall::channels::pump_bidi` — - use it, do not hand-roll - - alkcall ADR-049 (amendment 2) — the establishment phase; the + - alknet ADR-078 / alkcall ADR-050: two-pump shutdown-on-completion; + the helper is pinned upstream as `alkcall::channels::pump_bidi` + (use it, do not hand-roll) + - alkcall ADR-049 (amendment 2): the establishment phase; the establisher returns the negotiated handle via `Establishment::new` (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` - - alknet ADR-075 / alkcall ADR-039 — `ChannelsAdapter` / + - alknet ADR-075 / alkcall ADR-039: `ChannelsAdapter` / `ChannelManager` (substrate-agnostic demux; `params` is - ALPN-specific — for this crate, the SOCKS5 service identity/policy) - - alknet ADR-071 / alkcall ADR-034 — channels wire format (one-way - door); alkcall ADR-036 — channel 0 is pre-negotiated `alk/call`; - alkcall ADR-037 — channel lifecycle operations (`channel/open` on + ALPN-specific, i.e. for this crate the SOCKS5 service identity/policy) + - alknet ADR-071 / alkcall ADR-034: channels wire format (one-way + door); alkcall ADR-036: channel 0 is pre-negotiated `alk/call`; + alkcall ADR-037: channel lifecycle operations (`channel/open` on 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 - is the same shape, "further downstream" — alktunnels + is the same shape, "further downstream", per alktunnels 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 > `ServingConfig.identity` > transport), per-call opener identity on the open-op hooks @@ -398,5 +416,5 @@ dependency-graph failure is expected. to composition). `alknet-client/src/socks5.rs` and the quinn-proxy 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 - 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. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b16981..a6a2ff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ semver-compatible (additive API only), matching the 0.3.1 precedent. - **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 — + 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 @@ -32,7 +32,7 @@ semver-compatible (additive API only), matching the 0.3.1 precedent. 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 + 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 @@ -58,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 in principle to this crate's reverse-path adopt flow (the 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 strengthens. No API, wire-format, or documented-cap behavior change upstream. @@ -66,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 none of it. - **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 - 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 duplicate-type compile failure, not silent drift). @@ -90,10 +90,10 @@ semver-visible to downstream consumers (matching the alktunnels - **alkcall 0.9.0 → 0.10.0** (crates.io). The gateway ownership-seam release (review 012): `GatewayDispatch` gains `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 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`/ `register_openable_with_establisher` registry dispatch on the native wire, which 0.10.0 leaves untouched. All of this crate's alkcall @@ -108,7 +108,7 @@ semver-visible to downstream consumers (matching the alktunnels - **fuzz/shared harness pin** (the only bump contact): the harness declares its own `alkcall` pin (it constructs `ChannelOpenError` 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). ## [0.2.0] - 2026-10-14 @@ -135,7 +135,7 @@ consumers (matching the alktunnels 0.2.1 → 0.3.0 ride). `RelayError::PubTypedOpen` removal, the `register_imported`/`register_imported_all` return change, `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 all-features tests pass; wasm-clean verified. - **alktunnels 0.2.1 → 0.3.0** (the dependency-major ride on alkcall @@ -145,19 +145,19 @@ consumers (matching the alktunnels 0.2.1 → 0.3.0 ride). ## [0.1.0] - 2026-09-27 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 port; kernel binds exist only as explicit, feature-gated assembly shapes. Composed against by the alktunnels 0.2.1 graduation (the 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 parsers: the ASSOCIATE datagram codec and the RFC 1928 handshake; campaigns clean, no crate bugs. ### 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 fully owned code (MIT notice bundled; extraction per ADR-011): the wire consts, codecs, errors, the explicit typestate server API @@ -165,78 +165,78 @@ campaigns clean, no crate bugs. `T: AsyncRead + AsyncWrite + Unpin`), and the client `use_stream` path. Two deliberate changes carried from the fork point: BIND support in the base (ADR-007) and the `new_reply` publication. No - kernel-net code — the default crate is wasm-clean by construction. -- **The wire codec (`wire`, ADR-003)** — the ASSOCIATE datagram frame + kernel-net code; the default crate is wasm-clean by construction. +- **The wire codec (`wire`, ADR-003)**: the ASSOCIATE datagram frame (`[len: u16 BE][addr + data]`), framing applied exactly once at this layer; the `DatagramReader` incremental parser; the `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 `{}` params (the RFC conversation is the data plane; nothing to 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). -- **The three seams (`egress`, ADR-004)** — `DialFn` / +- **The three seams (`egress`, ADR-004)**: `DialFn` / `EgressFactory` / `BindAcceptFactory`: functions, not traits, at the three I/O planes; ownership rides the handle structurally (`TargetHandle` / `BindTargetHandle` / `BindAccept`); the egress readiness contract (`next_reply` `Pending` when nothing is ready) 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 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 (`socks5:bind` at BIND dispatch), the identity witness, and both wiring shapes: the free functions (`register_socks5_openable` / `socks5_session_establisher` / `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). -- **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 + - reaps; no `Clone`): [`Socks5ChannelSession`] (CONNECT — raw + reaps; no `Clone`): [`Socks5ChannelSession`] (CONNECT: raw pass-through, the `split`/`into_inner` postures), - [`AssociateSession`] (the framed datagram stage — + [`AssociateSession`] (the framed datagram stage: `send_datagram`/`recv_datagram`/`split`), [`BindSession`] (the reply#1/reply#2 reader + the `BindDataPlane` pass-through), and the token-carrying surface (`TokenClient` + the `*_with_token` - constructor twins — the cred-bearing front door's re-origination). -- **The `local` feature (ADR-008/009)** — the socket-backed seam - providers (`tcp_dial` / `udp_egress` / `bind_accept` — the explicit + constructor twins: the cred-bearing front door's re-origination). +- **The `local` feature (ADR-008/009)**: the socket-backed seam + providers (`tcp_dial` / `udp_egress` / `bind_accept`: the explicit bind decisions, resolving via the non-blocking `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, - 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 client's source IP pins on the first parsed datagram, replies 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 - listener (`ProducerDoor` — vanilla clients dial it, each accepted + dropped per RFC 1928's MUST; OQ-SK2-09) and the producer-side + listener (`ProducerDoor`: vanilla clients dial it, each accepted connection is served via `serve_socks5_over`; the ADR-014 idle-first-byte window + max-sessions cap knobs). -- **The `composed` feature (ADR-008/009)** — the alktunnels 0.2.1 - composed providers: `direct_egress` (the direct-op egress — a lazy +- **The `composed` feature (ADR-008/009)**: the alktunnels 0.2.1 + composed providers: `direct_egress` (the direct-op egress: a lazy destination→tunnel table, `DEFAULT_MAX_TUNNELS` bounded), and `bindfirst_accept` + `register_forwarded_serving` (the BIND accept whose listener lives on a bind-first listen resource; the `CorrelationRegistry` correlates the forwarded op's peer metadata 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 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 protocol-core integration suite (`tests/core-integration.rs`), and 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 - 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 `retained_bytes` bound) and `rfc1928_codec` (the RFC 1928 handshake 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 replay is the fuzz gate). 129 committed seeds; detached campaigns clean (`oom/timeout/crash: 0/0/0`, coverage saturated). The one diff --git a/Cargo.toml b/Cargo.toml index ae319e6..49a8b22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,14 +29,14 @@ composed = ["dep:alktunnels"] [dependencies] # The substrate (convention 11): pinned, bump deliberately, never # 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 # no change on this crate's paths (relevant in principle to the # bind-first adopt flow; the behavior only strengthens). alkcall = "0.10.1" tokio = { version = "1", default-features = false, features = ["rt", "sync", "io-util", "macros", "time"] } # 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 # diagnostics (review 001 N-2). tracing = "0.1" @@ -45,17 +45,17 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" bytes = "1" 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 # (convention 11's deliberate-bump rule applies to siblings too). # 0.5.0: the correlation-gate release (the forwarded op's serving half -# gains an establishment phase — additive API only, no wire change). +# gains an establishment phase (additive API only, no wire change). alktunnels = { version = "0.5.0", optional = true } [dev-dependencies] tokio = { version = "1", features = ["full", "test-util", "macros"] } 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 # (`#![cfg(feature = "composed")]`), the dev-dep keeps it compiling. alktunnels = "0.5.0" diff --git a/README.md b/README.md index 64ea3ec..04eaf22 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ SOCKS5 (RFC 1928) as a produced resource on [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) and [alktunnels](https://crates.io/crates/alktunnels) (`-L`/`-R` 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 explicitly configured to.** Kernel binds exist only as explicit, -feature-gated assembly shapes on either side — the consumer may -expose the resource on a local port (the front door vanilla clients — -curl, browsers, tun2proxy — point at), and the producer may serve +feature-gated assembly shapes on either side. The consumer may +expose the resource on a local port, the front door vanilla clients +(curl, browsers, tun2proxy) point at, and the producer may serve from a real listener. A producer/consumer protocol crate, no transport dependencies; the transport is whatever alkcall runs over. ## Quick start -### Producer — register the SOCKS5 open op +### Producer: register the SOCKS5 open op ```rust 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 // handler drives the RFC 1928 state machine in-stream over the // 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)?; ``` -### Consumer — open a SOCKS5 channel (CONNECT) +### Consumer: open a SOCKS5 channel (CONNECT) ```rust 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 // 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). let mut session = Socks5ChannelSession::connect( &client, @@ -75,14 +75,14 @@ let (mut read, mut write) = session.split().expect("pipe"); // ... raw pass-through: the channel IS the target connection ... ``` -### Consumer — UDP ASSOCIATE (the framed datagram stage) +### Consumer: UDP ASSOCIATE (the framed datagram stage) ```rust use alksocks::AssociateSession; use alksocks::rfc1928::util::target_addr::TargetAddr; // 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. let mut assoc = AssociateSession::associate(&client).await?; 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"); ``` -### Consumer — BIND (the reply#1/reply#2 reader) +### Consumer: BIND (the reply#1/reply#2 reader) ```rust use alksocks::BindSession; use alksocks::rfc1928::util::target_addr::TargetAddr; // 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 // accept → reply#2 → raw pass-through (ADR-007). 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 ... ``` -### Consumer — the front door (vanilla clients) +### Consumer: the front door (vanilla clients) ```rust use std::sync::Arc; @@ -127,15 +127,15 @@ let door = FrontDoor::builder() door.serve(client).await?; ``` -### Producer — the local listener shape (the other door) +### Producer: the local listener shape (the other door) ```rust use alksocks::producer::Socks5Producer; // 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 -// 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. let producer = Socks5Producer::builder() .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` | |------|--------------------| -| **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 | +| **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 | One connection hosts many SOCKS5 sessions (one per channel); sessions 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 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 unknown until the RFC command arrives). Everything else is RFC 1928, 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). - **ASSOCIATE**: the reply's BND.ADDR is a pure RFC handle (the 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 (`socks5:open`) at the registry, the command gate (`socks5:bind`) at 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. ### The seams @@ -191,42 +191,42 @@ The protocol layer is substrate-agnostic by construction (ADR-004): `DialFn` / `EgressFactory` / `BindAcceptFactory` are *functions, not traits* at the three I/O planes; ownership rides the handle 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. ### WASM target -The default crate (no features) compiles to `wasm32-unknown-unknown` -— the protocol layer for a sandboxed TS/Python adapter. The RFC 1928 +The default crate (no features) compiles to `wasm32-unknown-unknown`, +the protocol layer for a sandboxed TS/Python adapter. The RFC 1928 core is a targeted fork of [fast-socks5](https://github.com/dizda/fast-socks5) v1.0.0's `T`-generic subset, extracted in-tree as fully owned code (`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. ## Features | 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 | | `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; +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 +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. +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 — +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 @@ -236,18 +236,18 @@ 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 +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); +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 +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 @@ -256,7 +256,7 @@ there is no change on this crate's paths; patch rather than minor because both dependency bumps are semver-compatible. 0.3.2 rides the alktunnels bump 0.4.1 → 0.5.0 (the correlation-gate -release — the forwarded op's serving half gains an establishment +release: the forwarded op's serving half gains an establishment phase, `register_tunnel_forwarded_openable_with_establisher` + `ForwardedGate`: a stale/missing `listen` token fails the open as a typed `channel:open_failed` instead of success-then-silent-birth- @@ -280,11 +280,11 @@ cargo check --target wasm32-unknown-unknown # the wasm-clean guard ## Documentation -- [Architecture docs](docs/architecture/README.md) — the +- [Architecture docs](docs/architecture/README.md): the authoritative spec set (in the repository, not the published package): ADRs (001–014), the wire format, the producer/consumer 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. ## License diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 91bd455..c21d0f2 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 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 -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) --- -# alksocks — Architecture +# alksocks: Architecture A SOCKS5 (RFC 1928) producer/consumer protocol crate on alkcall 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 (`alk/tty`) and alktunnels (`alk/tunnel`). Where alktunnels realizes the `-L`/`-R` forwarding flavors, alksocks realizes the @@ -21,34 +21,34 @@ architecture spec docs and the ADRs. ## 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 0 (`docs/research/phase-0.md`) is converged (2026-09-14) and POC-complete (all numbered POCs passed or were dropped with the scope 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 review passes. 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`, ADR-003), the open-op params + typed error surface (`src/params.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 -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 -assembly layer (batch 2) landed with it: the `local` providers -(`src/local/` — tcp_dial / udp_egress / bind_accept, the producer-side -`ProducerDoor`, ADR-008/009), the front doors (`src/door.rs` — the +assembly layer (batch 2) landed with it: the `local` providers in +`src/local/` (tcp_dial / udp_egress / bind_accept, the producer-side +`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 -UDP door, ADR-005/003/014), and the composed providers -(`src/composed/` — the direct-op egress + the bind-first accept over +UDP door, ADR-005/003/014), and the composed providers in +`src/composed/` (the direct-op egress + the bind-first accept over alktunnels 0.2.0, ADR-007/008). Both cross-cutting integration suites pin the behavior end to end: `tests/core-integration.rs` (the 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). ## Documents @@ -56,33 +56,33 @@ set, mixed assembly, the assembly teardown matrix). | Document | Status | Description | |----------|--------|-------------| | [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 | | [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 | -| [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 | Title | Status | |-----|-------|--------| | [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 | -| [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 | -| [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 | -| [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 | -| [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 | -| [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) | +| [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 | +| [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 | +| [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 | +| [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) | +| [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) | | [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 -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 authoritative for the shared surfaces (establishment, channels wire 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). 2. **The RFC conversation is the data plane.** One ALPN, one open 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 change). 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 mapping. 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. 6. **Backpressure and limits are inherited, not redefined** (the 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). 7. **Wasm-clean default crate** (ADR-009): the default crate is 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' wasm-clean default (verified with the wasm check at implementation). 8. **The RFC 1928 core is owned** (ADR-013, superseding ADR-011's vendor posture): the extracted `T`-generic subset is a targeted - fork (`src/rfc1928/`, fork point `5b18d4d`) — project - conventions apply unconditionally, the lint-allow/verbatim + fork (`src/rfc1928/`, fork point `5b18d4d`); project + conventions apply unconditionally, and the lint-allow/verbatim apparatus is dissolved; `/workspace/fast-socks5` stays the differential-testing behavioral reference and the only place the 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`), alktunnels (`/workspace/@alkdev/alktunnels`) - 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`) \ No newline at end of file diff --git a/docs/architecture/bast.md b/docs/architecture/bast.md index 9886644..24626f7 100644 --- a/docs/architecture/bast.md +++ b/docs/architecture/bast.md @@ -3,11 +3,11 @@ status: 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)** 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 precedent), the mandatory ASSOCIATE datagram framing ([ADR-003](decisions/003-udp-handle-swap.md)) is @@ -28,36 +28,36 @@ feeds it. ## Scope 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 [ADR-001](decisions/001-alpn-and-open-op.md)), and a data plane inside the channel `BiStream` that is *mostly* RFC 1928 wire rules plus raw pass-through. 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 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 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 pass-through have no framing for BAST to describe. 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 datagram header (RSV/FRAG/ATYP/addr/port, front doors only). The 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. -- **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 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). ## 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 split of one frame's payload; on the wire `len` covers both, and `data`'s length is `len − len(addr)`, decodable because the ATYP determines the address length. The two-field split documents the 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 255-char domain would need 65766 frame bytes and must be rejected `Oversize` at frame time; wire.md §The Data Plane carries the @@ -95,7 +95,7 @@ arithmetic).) ## Annotations -### `AssociateFrame` — binary, exact +### `AssociateFrame`: binary, exact Maps 1:1 to the on-wire bytes: `len` as `uint16` big-endian (2 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 = channel lifetime). Frames are self-delimiting on the byte stream; a 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 -structurally and stated at payload level: `len == 0` — zero inner -bytes — is unreachable from a compliant peer, since `addr` is always +structurally and stated at payload level: `len == 0`, zero inner +bytes, is unreachable from a compliant peer, since `addr` is always ≥ 7 bytes). ### 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**: `Egress::next_reply` returns *inner* bytes (`addr + data`); the 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 once, its reader state persists across receives. The BAST describes 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 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) - [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 document) and alktunnels ADR-003 (the codec alksocks reuses) - POC #2 `poc-udp-associate-findings.md` (the frame's validation diff --git a/docs/architecture/consumer.md b/docs/architecture/consumer.md index 881515d..648e4f7 100644 --- a/docs/architecture/consumer.md +++ b/docs/architecture/consumer.md @@ -1,23 +1,23 @@ --- status: draft last_updated: 2026-09-25 (the cred-bearing door's call-connection -contract reconciled — TokenClient's door-only posture, one command per -accepted connection; the BIND spent posture reconciled — the +contract reconciled: TokenClient's door-only posture, one command per +accepted connection; the BIND spent posture reconciled: the 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) --- -# alksocks — The Consumer Half +# alksocks: The Consumer Half The consumer opens SOCKS5 channels against a produced service and speaks RFC 1928 inside them. Two consumption shapes, structurally 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 (wrapper-aware; the POC #1 `use_stream` / POC #2 associate 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 clients point at; the door bridges each accepted 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 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 `AsyncRead + AsyncWrite` itself, so the session over a `BiStream` is the intended use, not a hack); - target construction via `ToTargetAddr` (IP-string forms parse to - IP targets; domain form passes through unresolved — ADR-012); -- after the reply, the session is a raw byte pipe — the consumer + IP targets; domain form passes through unresolved, ADR-012); +- after the reply, the session is a raw byte pipe: the consumer reads/writes payload bytes directly (0 B framing, CONNECT). ### The associate session 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 addressing** (the handle is the channel; ADR-003); - `send_datagram(dst, bytes)` frames once (`[len: u16 BE][addr + data]`, the RFC 1928 address encoding) and writes the frame; - `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 datagram split across channel chunks survives a caller-driven 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 ends (association lifetime = channel lifetime); - **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 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; no second codec). The session then rides the pass-through data 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), 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 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 `reply1()` handle is a view (the advertised BND.ADDR + the raw 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. ### Error surface @@ -94,18 +94,18 @@ code surfaced through the session type. ## 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 BIND door): - **The CONNECT/BIND door**: bind a real TCP listener; for each 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 (below) is the only RFC work it does. - **The UDP door**: bind a real UDP socket; decode RFC-header'd 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 v1 posture). - **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. - **The cred-bearing door** (RFC 1929): a vanilla client carrying `user:pass` gets a real RFC 1929 conversation at the door; the - door maps the accepted credential to *local* configuration — - which channel identity/endpoint to open the producer channel - with — via a **static config table** (ADR-005: `user:pass →` + door maps the accepted credential to *local* configuration (which + channel identity/endpoint to open the producer channel with) via + a **static config table** (ADR-005: `user:pass →` channel identity/endpoint selection). The SOCKS password never crosses the channel as a SOCKS password; the channel identity 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 `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 - door's client is a door client; other shapes — the UDP door's - association, channels-native sessions — ride their own + door's client is a door client; other shapes, such as the UDP door's + association and channels-native sessions, ride their own connections to the same producer registration, one registration, many connections). The vanilla conversation is **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` -(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 target-construction posture, ADR-012); `recv_datagram()` returns -`Result, SocksSessionError>` — `None` is +`Result, SocksSessionError>`; `None` is the channel EOF (the association's clean end), never an empty datagram (F-2: an empty payload is a legal datagram). @@ -185,12 +185,12 @@ None currently blocking this document. ## References -- POC #1 (`poc-connect-wasm-findings.md` — the raw-pipe door, the - `use_stream` fit), POC #2 (`poc-udp-associate-findings.md` — the +- POC #1 (`poc-connect-wasm-findings.md`: the raw-pipe door, the + `use_stream` fit), POC #2 (`poc-udp-associate-findings.md`: the 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 - ADR-005** (consumer session owns teardown — the posture + ADR-005** (consumer session owns teardown; the posture inherited), the CF-005/CF-006 seam - [producer.md](producer.md) (the other half), [wire.md](wire.md) (the normative wire) \ No newline at end of file diff --git a/docs/architecture/decisions/001-alpn-and-open-op.md b/docs/architecture/decisions/001-alpn-and-open-op.md index 54c6d3f..34b113c 100644 --- a/docs/architecture/decisions/001-alpn-and-open-op.md +++ b/docs/architecture/decisions/001-alpn-and-open-op.md @@ -9,10 +9,10 @@ Accepted (2026-09-20, Phase 1) The crate rides alkcall channels: a produced SOCKS5 resource is an openable ALPN-op, opened by consumers via `ChannelClient` (alkcall 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 (`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 in-stream post-negotiation), so there is nothing for params to carry (`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 - **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 naming family, of which ADR-004 is the operative one). One ALPN 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 ALPNs are operations). All three RFC 1928 commands (CONNECT, BIND, UDP ASSOCIATE) ride this one op, selected in-stream at - command dispatch — the command is an RFC-1928 wire fact, not a - wire-format choice of ours. -- **Params: the trivial object** `{}` — schema `{type: object}`, - additionalProperties false. Nothing else is needed at open time - (the establisher is a session gate, not a dial — the target is + command dispatch (the command is an RFC-1928 wire fact, not a + wire-format choice of ours). +- **Params: the trivial object** `{}` (schema `{type: object}`, + additionalProperties false). Nothing else is needed at open time + (the establisher is a session gate, not a dial; the target is unknown). Extensibility: if a later need surfaces per-session options (e.g. a requested local bind hint), that is an **additive** params schema change while no params were ever required; consumers that send `{}` stay valid. Params are a self-contained JSON object identifying the produced resource (alkcall ADR-039's shape, following alktunnels ADR-001's template). -- **Reply: `{"channel_id": }`** — the Sub-typed operation +- **Reply: `{"channel_id": }`**: the Sub-typed operation reply, as with alktunnels. No extra reply fields in v1 (the establishment phase is a session gate and has nothing to project; 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 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. - Command-level differentiation (CONNECT vs ASSOCIATE vs BIND) lives in the RFC 1928 stream and in the ACL scope check at 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. - POC-validated: all three POCs served CONNECT/ASSOCIATE/BIND on 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 -- 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) - POC #1 `poc-connect-wasm-findings.md` (the trivial-params finding), POC #2 `poc-udp-associate-findings.md` (one-op regression guard), diff --git a/docs/architecture/decisions/002-protocol-base.md b/docs/architecture/decisions/002-protocol-base.md index 56fb9e8..d85db60 100644 --- a/docs/architecture/decisions/002-protocol-base.md +++ b/docs/architecture/decisions/002-protocol-base.md @@ -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 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-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, generic over `T`), iroh-socks5 (structurally the closest sibling, but 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 `Socks5ServerProtocol` accepts a channels `BiStream` directly (`T: AsyncRead + AsyncWrite + Unpin`), and POC #4 measured @@ -24,13 +24,13 @@ functionally complete (a full RFC 1928 CONNECT conversation passes ## Decision - **The protocol layer is built on fast-socks5's explicit typestate - API** — `Socks5ServerProtocol` (negotiate/finish_auth/ + API**: `Socks5ServerProtocol` (negotiate/finish_auth/ read_command/reply_success/reply_error), the client side `Socks5Stream::use_stream` / the associate session, and the pure codecs (`new_udp_header`/`parse_udp_request`). - **Only the explicit API is used.** The legacy `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. - **Base = v1.0.0, extracted in-tree and forked** (ADR-011 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 differential testing of RFC edge cases; the fork point is noted 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; refusals are never collapsed into generic errors (AGENTS.md convention 2). POC #1 validated the mapping (dial refusal → 0x05). @@ -46,11 +46,11 @@ functionally complete (a full RFC 1928 CONNECT conversation passes ## Consequences - 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: command-read (the dial/accept dispatch), `transfer` (the raw - two-pump), and — where the default handlers are unusable (UDP - ASSOCIATE's unconditional bind) — direct typestate driving. The + two-pump), and, where the default handlers are unusable (UDP + ASSOCIATE's unconditional bind), direct typestate driving. The interception-point map is spec'd in [producer.md](../producer.md). - iroh-socks5's design ideas (the `{addr, data}` frame, control-EOF association lifetime, FRAG≠0 refusal) were adopted at the design diff --git a/docs/architecture/decisions/003-udp-handle-swap.md b/docs/architecture/decisions/003-udp-handle-swap.md index 7c9eab1..846ecad 100644 --- a/docs/architecture/decisions/003-udp-handle-swap.md +++ b/docs/architecture/decisions/003-udp-handle-swap.md @@ -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 @@ -9,12 +9,12 @@ validated end to end 2026-09-14) 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 -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. 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 -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 (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 @@ -26,27 +26,27 @@ including the front door. 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, ADR-001). - **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`/ `DatagramReader`, re-implemented in first-party `src/wire.rs`; 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 (`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 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 post-reply garbage is refused structurally: the frame's first byte is ATYP, so any post-reply control byte parses as a garbage ATYP and fails the association loud). - **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 address. - **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. - **The datagram stage is `select!`-driven** with the egress 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 for the crate). Framing is applied exactly once, by the wire 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 `send_datagram` frames once; its `recv_datagram`'s incremental 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 cut dropped the reader state per call; the 2-byte-prefix chunk 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 the channel (the handle swap). Local binds remain optional 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 cost ~0.8 ms per composed destination, measured; LRU + dedup 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 it structurally). The BAST document for the frame is [bast.md](../bast.md). ## 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) - POC #2 `poc-udp-associate-findings.md` (the validation record + the readiness/framing/ownership findings) - alktunnels ADR-003 (the length codec + F-2), alktunnels - ADR-007 (the direct op — the composed egress vehicle) -- iroh-socks5 eval (`docs/research/iroh-socks5-eval.md` — the frame + ADR-007 (the direct op, the composed egress vehicle) +- iroh-socks5 eval (`docs/research/iroh-socks5-eval.md`, the frame design prior art) \ No newline at end of file diff --git a/docs/architecture/decisions/004-dial-callback-shape.md b/docs/architecture/decisions/004-dial-callback-shape.md index 39a3dd5..d7b4959 100644 --- a/docs/architecture/decisions/004-dial-callback-shape.md +++ b/docs/architecture/decisions/004-dial-callback-shape.md @@ -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 -Accepted (2026-09-20, Phase 1; shape resolved empirically — POC #1 -(2026-09-13), POC #2 (2026-09-14), POC #5 (2026-09-15)) +Accepted (2026-09-20, Phase 1; shape resolved empirically, with +POC #1 (2026-09-13), POC #2 (2026-09-14), POC #5 (2026-09-15)) ## 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 accept. alktunnels resolved the same question for its establisher 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. 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). The injected `DialFn(target) -> TargetHandle {read, write}` closure is called at the command-read interception point (`poc-connect-wasm-findings.md` §Structural findings). Per-target refusal is an RFC reply code; session-level refusal stays 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 (`poc-udp-associate-findings.md` §The per-association egress 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 wait's `select!` on the control-stream branch (the client-gone 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 function-shaped callbacks injected at registration/assembly: - - `DialFn(target) -> TargetHandle` (boxed halves, `+ Sync` — - satisfies the `ChannelPlan` payload bound; the bound binds - handle/plan payloads downstream — the SOCKS5 establisher + - `DialFn(target) -> TargetHandle` (boxed halves, `+ Sync`, + satisfying the `ChannelPlan` payload bound; the bound binds + handle/plan payloads downstream; the SOCKS5 establisher itself carries no plan, `Establishment::default()`) - `EgressFactory() -> impl Egress` (per-association instance; `next_reply` readiness contract: `Pending` when nothing is ready) - `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 own their channel sessions and socket halves inside the payload (the third witness of the teardown-at-birth pattern: POC #2's 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. - **The trait re-evaluation threshold is the same as alktunnels 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 composed providers (alktunnels direct-op egress, bind-first 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; the composed-provider helpers that reference alktunnels types live behind the composition feature, ADR-009). @@ -79,7 +79,7 @@ The POCs extended that finding to all three SOCKS5 seams: ## 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), OQ-SK-08 (the accept seam) - POC #1/#2/#5 findings (the three seams' empirical records) diff --git a/docs/architecture/decisions/005-auth-split-by-door.md b/docs/architecture/decisions/005-auth-split-by-door.md index 20af364..5e00b08 100644 --- a/docs/architecture/decisions/005-auth-split-by-door.md +++ b/docs/architecture/decisions/005-auth-split-by-door.md @@ -1,23 +1,23 @@ -# ADR-005: Auth Mapping — "Both," Split by the Door +# ADR-005: Auth Mapping: "Both," Split by the Door ## Status -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) +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) ## Context 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 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 nothing the identity seam doesn't already do, and the ACL system is strictly better than a shared secret. 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 requirement worth surfacing in the spec, not a protocol-layer concern. @@ -27,12 +27,12 @@ concern. - **Producer side: identity only.** The channels-native open is authorized by the alkcall ACL/identity seam. The producer's 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 → read_command`). The negotiation is real but the auth decision is vacuous in-band because authorization already happened at the 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 client that speaks the RFC greeting would misparse the greeting 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 presentation: a vanilla client carrying `user:pass` gets a real RFC 1929 conversation at the door. The door then maps the - accepted credential to *local* configuration — which channel - identity/endpoint to open the producer channel with — and that + accepted credential to *local* configuration (which channel + identity/endpoint to open the producer channel with), and that identity reaches the producer via the CF-005/CF-006 seam. **The SOCKS password never crosses the channel as a SOCKS password; the channel identity never reaches the vanilla client.** - **No fast-socks5 `AuthMethod` impl is required.** The door checks 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. - **The door's credential→identity mapping shape: static config.** The mapping is a caller-supplied configuration table - (`username/password → channel identity or endpoint selection`) - — static, declarative, assembly-layer. A pluggable lookup + (`username/password → channel identity or endpoint selection`), + static, declarative, assembly-layer. A pluggable lookup (callback) remains possible as an additive extension if a deployment needs dynamic resolution; v1 ships the static shape because the front door's use cases (tun2proxy's fixed `user:pass@host`, curl's `--proxy-user`) are single-credential or small-table shapes. - **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 greeting half. Door variant selection is an assembly-layer choice. @@ -70,15 +70,15 @@ concern. - Vanilla-client compatibility at the front door is real (RFC 1929), without polluting the channels-native path with shared secrets. - 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 boundary, same code shape. - 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 -- 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) - POC #1 `poc-connect-wasm-findings.md` (the identity-seam wiring finding; the raw-pipe front door) diff --git a/docs/architecture/decisions/006-access-control.md b/docs/architecture/decisions/006-access-control.md index 463e0f8..ed7b392 100644 --- a/docs/architecture/decisions/006-access-control.md +++ b/docs/architecture/decisions/006-access-control.md @@ -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 -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); consequence asymmetry pinned by the integration suite, 2026-09-24) ## 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). alktunnels resolved the sibling posture (its ADR-006): whatever ACL governs the resource governs everything reachable through it, plus whatever policy the SOCKS5 implementation itself applies downstream. -For SOCKS5, BIND adds an *inbound* capability — the accept-side twin -of arbitrary egress — so the open gate must distinguish it +For SOCKS5, BIND adds an *inbound* capability, the accept-side twin +of arbitrary egress, so the open gate must distinguish it (OQ-SK-08's security note): an identity with proxy egress must not implicitly get ingress listeners. POC #5 validated exactly this 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 `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 before the open handler). Scope-string convention: - `:` (`socks5:open`, `socks5:bind`) — + `:` (`socks5:open`, `socks5:bind`), following alktty's `TTY_OPEN_SCOPE` / alktunnels' `tunnel:open`/`tunnel:direct` shape. Stable once published. - **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 the same identity (POC #5 test 4). - **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 avoids a second policy layer (alktunnels OQ-TN-08's resolution 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 callback governs per-destination checks. - **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. - **Hub relaying**: the hub's terminate-and-re-produce applies per 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. ## Consequences - 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 own scope). The implication asymmetry is pinned by the integration suite (`tests/core-integration.rs`, the identity/scope matrix): `socks5:open` alone serves CONNECT + ASSOCIATE and is refused BIND (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 phantom channel); both scopes, granted explicitly, serve 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 POC-validated (POC #5). - 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) for the virtual-subnet translation-layer proposal, which is the same dial-callback shape. diff --git a/docs/architecture/decisions/007-bind-in-base.md b/docs/architecture/decisions/007-bind-in-base.md index 0939bfc..733976c 100644 --- a/docs/architecture/decisions/007-bind-in-base.md +++ b/docs/architecture/decisions/007-bind-in-base.md @@ -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 @@ -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 that makes it real (FTP active mode is the canonical vanilla reason) 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 front door. @@ -28,8 +28,8 @@ Nothing here is a one-way door. - **BIND ships in the base crate** (protocol layer), phase-model style: request → reply#1 carrying `BND.ADDR` → quiet wait (any - control byte after reply#1 is protocol garbage; the session ends — - exactly two exits: the accept, or the control stream's EOF/ + control byte after reply#1 is protocol garbage; the session ends, + with exactly two exits: the accept, or the control stream's EOF/ garbage/error) → exactly one inbound accept → reply#2 carrying the peer address → raw pass-through data plane (zero per-packet 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 provider-backed accept variants (plus the wrapper-aware in-band 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 `local_addr`), a real peer in reply#2, vanilla front-door clients work. **This is the default accept variant** for the 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_ openable` + `BindFirstInjections`, alktunnels 0.2.0); per- listener ACL for free, hub-relayable. **Vanilla-fidelity with - no stand-ins** since the graduation: the factory no longer - carries an expected `bind_addr` — reply#1 advertises the real - OS-chosen `bound` (the open-op reply's `bound` field, + no stand-ins** since the graduation: the factory no longer + carries an expected `bind_addr`; reply#1 advertises the real + OS-chosen `bound` (the open-op reply's `bound` field, alkcall 0.8.0 reply projection); reply#2 carries the real peer (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 provider is wired: the handle-swap reading applied to BIND (the reply is RFC-compatibility surface; the data plane is the channel). - **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 refusals (the bind-first graduation's stated second win; `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 + ASSOCIATE, all three on one op (POC #5 test 7 regression-guarded 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; the POC's pinned workaround (reply#1 first, the tunnel open rides the accept future) is retired with the graduation. diff --git a/docs/architecture/decisions/008-provider-defaults.md b/docs/architecture/decisions/008-provider-defaults.md index 2b32520..2586fce 100644 --- a/docs/architecture/decisions/008-provider-defaults.md +++ b/docs/architecture/decisions/008-provider-defaults.md @@ -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 -Accepted (2026-09-20, Phase 1; the A/B assembly-layer choice — -OQ-SK-01's residue — with the composed vehicle settled by +Accepted (2026-09-20, Phase 1; the A/B assembly-layer choice +(OQ-SK-01's residue), with the composed vehicle settled by alktunnels 0.2.0 ADR-007) ## 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 remained was the **default provider set**: where does the dial callback come from in the base crate's optional shapes? Option A -(dial via alktunnels — the composed shape), Option B (dial directly -— local TCP), Option C (injected dialer trait — rejected, ADR-004). +(dial via alktunnels, the composed shape), Option B (dial directly, +local TCP), Option C (injected dialer trait, rejected, ADR-004). 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 -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. ## Decision @@ -25,7 +25,7 @@ browsing. - **Local-socket providers** (behind the `local` feature): a real `TcpStream` dial (CONNECT), a real `UdpSocket` egress (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 `TcpListener` accept (BIND, ADR-007 variant 1). These are the faithful-RFC shapes and the simplest assembly story. @@ -33,10 +33,10 @@ browsing. dial callback opens an alktunnels direct-op channel naming the target (`open_direct`, `Substrate::Tcp`/`Substrate::Udp`); the 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 (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 the providers ship *in the crate* rather than forcing every deployment to hand-roll them. The feature gates and module @@ -46,11 +46,11 @@ browsing. The feature-gated providers are convenience constructors over the same seams. A missing callback means that command refuses 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 condition. - **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 callback itself. diff --git a/docs/architecture/decisions/009-feature-gates.md b/docs/architecture/decisions/009-feature-gates.md index c25fd38..a9c0c7e 100644 --- a/docs/architecture/decisions/009-feature-gates.md +++ b/docs/architecture/decisions/009-feature-gates.md @@ -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 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 -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` + `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 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 platform-specific or sibling-dependent code need gating: 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 - helpers — ADR-008's composed variants), which need the + helpers, ADR-008's composed variants), which need the alktunnels dependency. The vendor originally contributed a third gate: its kernel-net 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 -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 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. - **The default build has no `net` feature**: the extracted vendor contains zero kernel-net code, so the wasm-clean subset is the - whole crate — there is nothing to gate (ADR-011 §Amendment). -- **`local` (default-off)**: the crate's first-party local backends - — the TCP dial, the UDP egress socket, the BIND listener, and the - front doors (the consumer's ssh `-D` local exposure; the + whole crate; there is nothing to gate (ADR-011 §Amendment). +- **`local` (default-off)**: the crate's first-party local backends, + namely the TCP dial, the UDP egress socket, the BIND listener, and + the front doors (the consumer's ssh `-D` local exposure; the producer's local backend). Brings `tokio/net` + `socket2` into the graph. Non-wasm by design (enabling it on wasm is a build error, the alktty posture). - **`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 protocol layer never references alktunnels types; the composed module does, injected at the assembly layer (ADR-004/ADR-008). - **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 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` 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 DNS-resolve helper, the net-only tests), the `socks4` module, and 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. - Features collapse to two: **`local`** (first-party socket providers + front doors, ADR-008; brings `tokio/net` + `socket2` 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. - POC #4's gate map survives as the *extraction recipe* (its 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 -- 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 substance *is* the wasm-relevant surface. - The verification commands cover the gates: `cargo test` (default), `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. - The vendor's carried changes (BIND, `new_reply` publication) live 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. ## 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 (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), alktty's `local` feature (the posture precedent), alktunnels ADR-004 + overview.md (the same feature split) \ No newline at end of file diff --git a/docs/architecture/decisions/010-channel-capacity.md b/docs/architecture/decisions/010-channel-capacity.md index 378c55d..fb22695 100644 --- a/docs/architecture/decisions/010-channel-capacity.md +++ b/docs/architecture/decisions/010-channel-capacity.md @@ -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 @@ -14,12 +14,12 @@ CONNECT channel per flow; composed UDP egress spends a downstream channel per destination; BIND spends an accept resource per listener). A proxy-shaped deployment will discover the cap in production unless the crate documents the posture. The caps are -policy knobs — configurable without a wire change (the `u32` -channel-ID wire space is ~4 billion) — so the resolution is config +policy knobs, configurable without a wire change (the `u32` +channel-ID wire space is ~4 billion), so the resolution is config plus documentation, never wire. 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 the 0-B pass-through that lets the forked `transfer` drop in unchanged, imposes chunk boundaries on a stream RFC 1928 treats as @@ -28,14 +28,14 @@ convention (AGENTS.md #10). ## 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. - **Assembly-layer config is the mechanism**: a proxy-shaped deployment raises the per-connection/per-identity caps at registration; the default stays conservative for everyone else. - **Documented proxy-workload recommendation**: the crate docs 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 accept), so the per-connection cap can rise safely relative to 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 leans on the rest. The per-identity DoS brake is arguably *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 command shapes are well-known and bounded). - **Revisit trigger** (OQ-SK-09's): if a deployment hits a fixed 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. ## Consequences @@ -57,7 +57,7 @@ convention (AGENTS.md #10). - Deployments get the guidance before they hit the cap; no protocol surface changes. - 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. ## References diff --git a/docs/architecture/decisions/011-inline-vendor.md b/docs/architecture/decisions/011-inline-vendor.md index f37d71e..6128eda 100644 --- a/docs/architecture/decisions/011-inline-vendor.md +++ b/docs/architecture/decisions/011-inline-vendor.md @@ -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 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 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 (extraction, not wholesale copy; the net gate dissolves) describe 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 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 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 (AGENTS.md convention 4). POC #4 measured the gate: ~120 changed lines across 5 files, all `#[cfg]` insertions; the wasm-clean subset is functionally complete (a full RFC 1928 CONNECT 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 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` unconditionally and derives the reply port from that kernel socket; the channels path cannot use it (verified 2026-09-13). The channels path drives the typestate directly; making the bind 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 (POC #5's courtesy item, now a vendor change). Two facts settled the posture: the ownership premise was wrong (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 -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.) ## Decision - **Extract the `T`-generic subset into this crate** (see §Amendment 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 zero kernel-net code and is wasm-clean by construction. - **Carry the four needs in the same vendor**: the net gate, BIND (the typestate-driven shape, ADR-007), the `run_udp_proxy_custom` 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.) - **Note the vendor point (v1.0.0) in the vendored code**; keep the MIT license obligations (upstream is MIT; preserve the license header per file or a bundled notice). - **`/workspace/fast-socks5` stays the differential-testing 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. - **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. ## 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 the wrapper (the carried changes are first-class, not patches). - 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 deliberate and bounded: the extraction cut + the carried changes + 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 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 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"); no crate consumer routes through the kernel-net region (the 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 - (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"). - **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 to two: `local` (the crate's first-party socket providers and 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 (AGENTS.md convention 4); `tokio/net` rides `local` alone. - **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 - `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 - 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 changes** touch extracted code. - **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 re-pointed at the crate's `tracing` (the alk* convention); the 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); `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` traits are native async-fn-in-trait); the `socks4` module and the legacy `Config`/`Authentication` layer are 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-08 (the BIND + `new_reply` needs), OQ-SK-03 (the bind seam) -- POC #4 + POC #1 (`poc-connect-wasm-findings.md` — the measured - gate), POC #5 (`poc-bind-findings.md` — the BIND typestate +- POC #4 + POC #1 (`poc-connect-wasm-findings.md`: the measured + gate), POC #5 (`poc-bind-findings.md`: the BIND typestate driving + the `new_reply` finding) - ADR-002 (the protocol base), ADR-007 (BIND), ADR-009 (the feature gates); `/workspace/fast-socks5` (the reference checkout, v1.0.0) \ No newline at end of file diff --git a/docs/architecture/decisions/012-target-policy.md b/docs/architecture/decisions/012-target-policy.md index 4eea8ed..2377673 100644 --- a/docs/architecture/decisions/012-target-policy.md +++ b/docs/architecture/decisions/012-target-policy.md @@ -3,7 +3,7 @@ ## Status 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 policy) @@ -11,15 +11,15 @@ policy) 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 -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 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 ACL). -The proposal's key property: the mechanism needs **no wire change** -— DST.ADDR and BND.ADDR are arbitrary RFC fields, so virtual +The proposal's key property: the mechanism needs **no wire change**; +DST.ADDR and BND.ADDR are arbitrary RFC fields, so virtual addresses ride ordinary CONNECT/BIND requests; the translation layer is the dial callback (`dial(target)` matches a virtual subnet against a per-identity resource table). @@ -34,13 +34,13 @@ against a per-identity resource table). dialer-refuses. - **Domain-form targets: pass through unresolved to the dialer.** 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 have no resolver at all, and the composed variant's targets ride the alktunnels direct op (producer-side resolution there, per alktunnels ADR-007's own posture). fast-socks5's `dns_resolve` 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. - **The virtual-subnet translation layer is assembly-layer policy, 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 subnet→resource table there (per-identity scoping = resources + 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 virtual subnet becomes routable from vanilla apps: `connect(10.x.y.z:p)` is an ordinary CONNECT the dialer 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 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 materializes with concrete needs, a translation-table helper can 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. - DNS posture is uniform: producer-side resolution everywhere (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-SK2-07 (the deferred helper question this ADR's resolution 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) -- 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), ADR-008 (the provider defaults) - 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) \ No newline at end of file diff --git a/docs/architecture/decisions/013-targeted-fork.md b/docs/architecture/decisions/013-targeted-fork.md index 03e4f6a..6f4b504 100644 --- a/docs/architecture/decisions/013-targeted-fork.md +++ b/docs/architecture/decisions/013-targeted-fork.md @@ -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 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) ## Context ADR-011 pinned the inline-vendor posture as **extraction** (the `T`-generic subset copied in-tree, kernel-net region not copied; the -net gate dissolving), and the vendor group — five implementation -tasks plus the review gate — landed it in commits `57b107b..5b18d4d`, +net gate dissolving), and the vendor group (five implementation +tasks plus the review gate) landed it in commits `57b107b..5b18d4d`, with the review (`tasks/implementation/ review-vendor-extraction.md`) as the fidelity gate: the file-by-file 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 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 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`. -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. -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. -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, `match_like_matches_macro`, `io_other_error`, `wrong_self_ convention`, the F-4 verbatim string) to keep the diff mechanical. Category 5 exists solely to keep categories 1–4 reconcilable by a 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 vendor posture's maintenance apparatus (verbatim preservation, the 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 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 maintained a second, weaker instruction set ("verbatim + adjustments 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 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 history (`57b107b..5b18d4d`), the review is the recorded fidelity 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 (`alksocks::socks5`) and collide with the future module structure (the producer/consumer/adapter layers are also SOCKS5-shaped). - `rfc1928` names what the module *is* — the RFC 1928 wire - vocabulary and the state machines — permanent and collision-free. + `rfc1928` names what the module *is* (the RFC 1928 wire + vocabulary and the state machines), permanent and collision-free. - **The lint-allow posture dissolves.** The module-level `#[allow]`s and the "verbatim lint posture" module-doc paragraphs are removed; 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 untouched. Textual-diff discipline is retired; behavioral 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 kernel-net region for a standalone vanilla-server example). 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 fast-socks5 fix (a reply-code mapping correction, a codec fix) 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 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, unconditionally); the lint-allow tax and the adjustment-list 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 structurally. - **Positive**: the module name is stable for the crate's future (no `alksocks::socks5` stutter, no `socks5::producer` collision 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 checkout, guarded by the differential suite. Assessed low-risk (quiet upstream, well-trodden RFC surface). @@ -139,10 +139,10 @@ vendor.md, and the review task. "The tree as of the review commit ## References - 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) - `tasks/implementation/review-vendor-extraction.md` (the fidelity gate + F-1), `docs/architecture/rfc1928.md` (the owned-core spec) -- `/workspace/fast-socks5` (v1.0.0, MIT — the behavioral +- `/workspace/fast-socks5` (v1.0.0, MIT, the behavioral reference for differential testing) \ No newline at end of file diff --git a/docs/architecture/decisions/014-idle-timeout-posture.md b/docs/architecture/decisions/014-idle-timeout-posture.md index 485cfea..0574bdd 100644 --- a/docs/architecture/decisions/014-idle-timeout-posture.md +++ b/docs/architecture/decisions/014-idle-timeout-posture.md @@ -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 -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`/ -`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) ## 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 pump handler's spawned task parks on the RFC handshake (`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 (`register_openable_with_establisher`'s default, `timeout: None`, alkcall ADR-049 §2) governs only the open-op round trip, not the -in-stream conversation. A silent peer therefore holds — per idle -channel — one spawned task and one channel entry (bounded buffers, +in-stream conversation. A silent peer therefore holds, per idle +channel, one spawned task and one channel entry (bounded buffers, 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 -channel" — implicit, never decided. +channel", implicit, never decided. The mitigations are real: the 256-channel per-connection cap (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 with `socks5:open` can hold 256 parked tasks + 256 channel entries indefinitely *without sending a byte*. And batch 2's providers (`local`, `composed`, ADR-008) are about to route *vanilla clients* 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 layer's problem, and the protocol layer is where a timeout seam would live. The review offered two options: (1) idle timeout is the substrate's/ -assembly's job — the protocol layer never times out, the DoS posture -recorded explicitly; (2) a producer-side idle bound — an optional +assembly's job: the protocol layer never times out, the DoS posture +recorded explicitly; (2) a producer-side idle bound: an optional per-session inactivity timeout at the pump level (config on `Socks5ProducerBuilder`), racing the handshake/stage reads against a 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 quiet-but-alive association (association lifetime = channel 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 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 contracts (association lifetime = channel lifetime; FTP-length 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. - **The two sides differ in composability.** The consumer sessions - are plain futures at the caller — a caller wanting an idle bound - wraps the constructor (or the session's reads) in + are plain futures at the caller: a caller wanting an idle + bound wraps the constructor (or the session's reads) in `tokio::time::timeout` itself; no decision is needed consumer-side. 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. Also recorded (an upstream-fidelity correction to the review's sketch): upstream's connect-timeout path maps its *dial* timeout to `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` `util/stream.rs`). Relevant only if option 2 is ever built. ## 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 handshake, the BIND quiet wait, and the association lifetime end on 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. - **No inactivity bound exists in `src/`.** The handshake and stage reads park like any other phase: no idle timer, no keepalive, no 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: ACL → per-connection cap → per-identity cap → door/dial policy): - **Channels shapes**: parked-task count is bounded by the per-connection cap (ADR-040) and the per-identity cap (ADR-041); 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. - **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 accept path owns its defense; the batch-2 door author inherits these obligations knowingly (the wire.md §UDP ASSOCIATE "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 (door-observable, protocol-agnostic; this is the slowloris 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 (ADR-005) is the natural knob; 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 bind-first listener's front. - 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. - **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 legal" was the explicit call. ADR-014 records the *same* resolution 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 designing BIND; ADR-014 decides the rest by recording the inherited 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 optional per-session inactivity bound (builder config defaulting to `None` = this posture), enforced by racing the handshake/stage reads against a sleep timer (`tokio::time` is already in the 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 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 - (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 - enforce the bound at its doors or assembly — a peer class outside - both the cap regime and any door policy — with measured evidence + enforce the bound at its doors or assembly (a peer class outside + both the cap regime and any door policy), with measured evidence the aggregate hold is unacceptable. ## Consequences - No code changes this round; the posture is recorded. The spec lines (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. - The batch-2 door tasks inherit the recorded accept-path obligations; this ADR is the timeout-posture gate those tasks may depend on (it precedes any `local`/`composed` door work). - 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 decision does not touch it. - 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 finding), §S-1 (the adjacent rate posture), §N-2 (the "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 layered-defense + config-not-protocol pattern this follows), [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, [producer.md](../producer.md) §The Open Handler (the spec lines updated) -- POC findings (the stage's measured shapes — the "sufficient for +- POC findings (the stage's measured shapes; the "sufficient for 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) \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index 1686285..c2e5bef 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -1,46 +1,46 @@ --- 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; -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 continue as OQ-SK2-06..) --- -# alksocks — Open Questions +# alksocks: Open Questions Centralized tracker. The Phase 0 OQ ledger (`docs/research/ 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 numbering (SK2 = the architecture-set ledger, distinct from 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 sequence starts at the numbers in use. Format per `docs/sdd_process.md`. ## 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) -- **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 provider set) - **Priority**: high - **Resolution**: 2026-09-20 (Phase 1). The dialer-trait-vs-callback 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 crate ships local-socket providers (`local`) and composed 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 ### OQ-SK-03: UDP ASSOCIATE without binding - **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 - **Resolution**: 2026-09-20 (Phase 1; baseline settled 2026-09-13, 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). - **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) -- **Status**: resolved — [ADR-007](decisions/007-bind-in-base.md) +- **Status**: resolved: [ADR-007](decisions/007-bind-in-base.md) - **Priority**: high - **Resolution**: 2026-09-20 (Phase 1; POC #5 validated 2026-09-15). BIND ships in the base crate, phase-model style; the accept 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 establishment bound covers only the open-op round trip; the 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 -### 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) -- **Status**: resolved — [ADR-005](decisions/005-auth-split-by-door.md) +- **Status**: resolved: [ADR-005](decisions/005-auth-split-by-door.md) - **Priority**: medium - **Resolution**: 2026-09-20 (Phase 1; the posture settled 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 - **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/ 013-targeted-fork.md) (the ownership posture) - **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 of the extraction-review commit (`5b18d4d`), module `src/rfc1928/`; 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). - **Cross-references**: ADR-002, ADR-009, ADR-011, ADR-013 ### OQ-SK-07: POC register - **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) - **Priority**: low - **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) - **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 - **Resolution**: 2026-09-20 (Phase 1). No wire change (rejected permanently); assembly-layer config is the mechanism; the crate docs carry the proxy-workload recommendation with the memory 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). - **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 - **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) (target policy + the virtual-subnet layer) - **Priority**: high @@ -147,14 +147,14 @@ sequence starts at the numbers in use. Format per ### OQ-SK-06: noq client support - **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) - **Priority**: low - **Resolution**: 2026-09-14 (Phase 0). The quinn/noq `AsyncUdpSocket` client wrapper is alknet's ancestor-shaped component (ADR-090 + the quinn-proxy POC live there); it neither 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 ### OQ-SK2-06: The sandboxed-wasm-consumer adapter story @@ -166,27 +166,27 @@ sequence starts at the numbers in use. Format per - **Priority**: low - **Impacts**: how much wasm surface to exercise in the default crate (a product/docs input). Blocks nothing in the protocol - design — the wasm-clean invariant (ADR-009) holds regardless. -- **Blocked on**: a concrete sandboxed-adapter use case — a TS/Python + design; the wasm-clean invariant (ADR-009) holds regardless. +- **Blocked on**: a concrete sandboxed-adapter use case: a TS/Python (or other) adapter embedding the alksocks consumer session in a wasm runtime. Tracker task id: `architecture/oq-sk2-06-wasm-adapter-story` (`tasks/architecture/oq-sk2-06-wasm-adapter-story.md`). - **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 `alknodes` assembly concept carries no named wasm-adapter plan. Nothing suite-side waits on this OQ, and this OQ waits on nothing - suite-side (the adapter's substrate — the wasm-clean default crate - — is complete and continuously gate-verified; a downstream glue + suite-side (the adapter's substrate, the wasm-clean default crate, + is complete and continuously gate-verified; a downstream glue crate can embed it today with zero further alksocks work). The Schrödinger-bind check (both sides deferring to each other forever) came up empty; the OQ stays an unowned external trigger 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. -- **Resolution**: Not yet decidable. The use case isn't concrete yet - — the adapter story is a product input whose shape would tell us +- **Resolution**: Not yet decidable. The use case isn't concrete yet: + the adapter story is a product input whose shape would tell us whether the wasm consumer needs surface beyond the channels-native session (e.g. bindings ergonomics). The 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) - **Door type**: two-way - **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 tracks whether a *translation-table helper* should graduate into 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: `architecture/oq-sk2-07-virtual-subnet-helper` (`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 crate-level helper would need real deployment requirements to 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 a real deployment concern (NAT is common), and the RFC's own drop 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) - **Door type**: two-way (additive; the current shape is RFC-faithful) - **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 **latest-seen port** from that IP (the NAT port-rebind case keeps 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 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). - **Option 3 (the complete fix, deferred)**: per-ASSOCIATE relay sockets spawned by the TCP door, with the expected peer IP derived 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 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 the wildcard bind *and* suffers address-level rebinds. - **Blocked on**: a concrete wildcard-bind deployment with 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). - **Cross-references**: review 003 S-1, ADR-003 (the handle swap), 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-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 - 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 deferred-OQ blocker-task half) \ No newline at end of file diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index bf05fec..ce73a74 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,14 +1,14 @@ --- status: draft -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 — -the idle-timeout posture; the targeted fork — ADR-013, the module is -`src/rfc1928/`; the vendor extraction amendment — no `net` feature — -2026-09-21; Phase 1 initial draft 2026-09-20 — the OQ-SK ledger +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: the +idle-timeout posture; the targeted fork, ADR-013, the module is +`src/rfc1928/`; the vendor extraction amendment, no `net` feature, +2026-09-21; Phase 1 initial draft 2026-09-20; the OQ-SK ledger promoted into ADRs 001-012 and open-questions.md) --- -# alksocks — Overview +# alksocks: Overview A SOCKS5 (RFC 1928) producer/consumer protocol crate on alkcall 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 sibling of alktty (`alk/tty`) and alktunnels (`alk/tunnel`) on the 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 covers the crate's purpose, the resource model in brief, dependencies, 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 `ChannelCore::register_openable_with_establisher`, alkcall 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 - 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 replies; the dial/egress/accept seams (ADR-004) supply the I/O 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* means a client that knows the in-band conventions (the sentinel BND.ADDR, in-band frames) rather than a vanilla RFC 1928 client. - Optionally it - exposes the service locally: the **front door** — a real - `socks5://127.0.0.1:port` endpoint relaying each accepted - connection into a channel (the ssh `-D` local-exposure shape; the - path curl/browser/tun2proxy point at). + Optionally it + exposes the service locally through the **front door**, a real + `socks5://127.0.0.1:port` endpoint relaying each accepted + connection into a channel (the ssh `-D` local-exposure shape; the + path curl/browser/tun2proxy point at). - **The optional producer local backend**: the producer may serve 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). 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 loop. - 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 the local backends. Neither substrate leaks into the protocol 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 `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. Failure is a typed `channel:open_failed` (alkcall ADR-049); no 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 `socks5:bind` (the command gate, ADR-006). 3. **Data plane.** CONNECT: the dial seam (ADR-004) produces boxed - halves; `pump_bidi` two-pumps channel ↔ target — raw - pass-through, 0 B overhead. ASSOCIATE: the reply's BND.ADDR is a + halves; `pump_bidi` two-pumps channel ↔ target (raw + pass-through, 0 B overhead). ASSOCIATE: the reply's BND.ADDR is a pure RFC handle (the sentinel in-band); the datagram stage rides `[len: u16 BE][addr + data]` frames (ADR-003, the handle swap) to the egress seam. BIND: reply#1 → quiet wait → exactly one accept @@ -119,34 +119,34 @@ protocol layer; crate-level vocabulary is producer/consumer. ## Dependencies ``` -alksocks (default — wasm-clean) +alksocks (default: wasm-clean) ├── alkcall 0.10.1 (core types: Connection, ProtocolHandler, BiStream, │ AuthContext, Identity, AccessControl, OwnershipProvider; │ channels: ChannelCore/ChannelClient, │ register_openable_with_establisher, pump_bidi, │ 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) ├── 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 the first-party local backends + front doors alksocks (composed feature) └── 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 ungated channels surface (verify with the wasm check) ``` 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, so there is no `net` feature at all (ADR-011 §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 differential-testing behavioral reference. @@ -154,44 +154,44 @@ differential-testing behavioral reference. | 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) | -| `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 ``` src/ - lib.rs — re-exports; crate docs - rfc1928/ — the RFC 1928 core: the targeted fork of + lib.rs : re-exports; crate docs + rfc1928/ : the RFC 1928 core: the targeted fork of fast-socks5's T-generic subset (ADR-013; extraction per ADR-011; fork point noted in-tree): consts, codecs, errors, typestate, client use_stream path; the fork's two carried changes (BIND reply surface, 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) - 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 codec (ADR-003; framing is wire-layer, applied 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 (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 - 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 credential→identity mapping (ADR-005); the UDP front door's RFC codec boundary (ADR-003) - egress.rs — the egress/accept seam types (DialFn/EgressFactory/ - BindAcceptFactory contracts — ADR-004) - local/ — feature `local`: the socket-backed provider + egress.rs : the egress/accept seam types (DialFn/EgressFactory/ + BindAcceptFactory contracts, ADR-004) + local/ : feature `local`: the socket-backed provider constructors (dial/egress/accept) - composed/ — feature `composed`: the alktunnels-composed + composed/ : feature `composed`: the alktunnels-composed 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) ``` @@ -205,7 +205,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/). | 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 | | [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 | @@ -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 | | [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 | -| [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 | | [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 | @@ -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). Key questions affecting this document: -- **OQ-SK2-06**: sandboxed-wasm-consumer adapter story (deferred(scope) - — a product input, nothing waits on it; the trigger is external by +- **OQ-SK2-06**: sandboxed-wasm-consumer adapter story (deferred(scope); + a product input, nothing waits on it; the trigger is external by 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) -- **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 +- **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 control-conn-derived expected peer, deferred on a concrete wildcard-bind deployment) diff --git a/docs/architecture/producer.md b/docs/architecture/producer.md index 9d21a27..84ad719 100644 --- a/docs/architecture/producer.md +++ b/docs/architecture/producer.md @@ -1,10 +1,10 @@ --- status: draft 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 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, `required_scopes = ["socks5:open"]` (`SOCKS5_OPEN_SCOPE`, ADR-006); - params schema `{type: object}`, `additionalProperties: false` - (trivial — ADR-001); + (trivial, ADR-001); - the establisher: validates/records the opener identity (the CF-005/ - CF-006 seam — ADR-005) and applies session-level policy. It runs - `Establishment::default()`-shaped (no plan payload) — the dial + CF-006 seam, ADR-005) and applies session-level policy. It runs + `Establishment::default()`-shaped (no plan payload): the dial happens post-establishment, so there is nothing to thread establisher→handler (POC #1's wire note). Failure semantics: a scope-less identity is `FORBIDDEN` at the 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; target-level refusal is in-stream RFC (below). **Harness wiring requirement** (POC #1, POC #2 re-witnessed): the 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 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. ## The Open Handler: RFC Conversation + Command Dispatch 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 -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 only the open-op round trip, ADR-049 §2); a silent peer holds the 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 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; authorization already happened at the open gate). -2. **Command read**: `read_command()` returns `(cmd, target)` — - the dispatch point: +2. **Command read**: `read_command()` returns `(cmd, target)`, the + dispatch point: - **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 plane. Refusal: `reply_error` with the mapped RFC code (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` - 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 still serves the same identity (POC #5 test 4). Then the BIND shape (ADR-007, below). - **UDP ASSOCIATE** → `reply_success(sentinel)` (the RFC-compatibility handle, ADR-003) then the datagram stage. 3. **Data plane** per command (wire.md §The Data Plane): - - CONNECT: `pump_bidi` inline inside the handler task (the - returned `JoinHandle` tracks the data-plane lifetime — R-02, - alkcall review 007, + - CONNECT: `pump_bidi` inline inside the handler task (the + returned `JoinHandle` tracks the data-plane lifetime: R-02, + alkcall review 007, `/workspace/@alkdev/alkcall/docs/reviews/ 007-establishment-follow-ups-review.md`; early return = teardown-at-birth). @@ -101,7 +101,7 @@ substrate-free `serve_socks5_over`): 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 `_` bindings of owning values; the third teardown-at-birth witness). @@ -109,21 +109,21 @@ Contracts (POC-pinned, load-bearing): frames once. - **The accept future owns its resources end-to-end**; the quiet 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). ## 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). -- `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; ingress capability, never implied by `socks5:open`. - Target policy: the dialer/egress/accept callbacks refuse (RFC reply codes); no allowlist hook in v1 (ADR-006/ADR-012). Whatever ACL governs the socks5 resource governs everything reachable through it, plus the callbacks' policy. -- Optional: consult `OwnershipProvider` for resource ownership — a +- Optional: consult `OwnershipProvider` for resource ownership, a registration-time choice. ## 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: a feature-gated backend binds `socks5://127.0.0.1:port` (or any 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 in-stream shape: greeting, command, reply, data plane, all on the accepted socket), and the seams dial/egress/accept locally. The same -door shape in reverse: this is a *front door for the producer side* -— vanilla clients reach a locally-served SOCKS5 resource through it +door shape in reverse: this is a *front door for the producer side*; +vanilla clients reach a locally-served SOCKS5 resource through it (`src/local/producer_door.rs`, the `ProducerDoor`/builder/guard 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. 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 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 -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 -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 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 -caller; the pump handler's identity-derived gate does not apply). +identity on an accepted socket; the accepted socket is the whole +caller, and the pump handler's identity-derived gate does not apply). ## Wiring (Assembly Layer) ```rust let producer = Socks5Producer::builder() - .dial(dial_fn) // DialFn — required for CONNECT - .egress(egress_factory) // EgressFactory — required for ASSOCIATE - .accept(bind_accept_factory) // BindAcceptFactory — required for BIND + .dial(dial_fn) // DialFn: required for CONNECT + .egress(egress_factory) // EgressFactory: required for ASSOCIATE + .accept(bind_accept_factory) // BindAcceptFactory: required for BIND .build(); -// register(&core, ®istry, auth, witness) — the establisher rides +// register(&core, ®istry, auth, witness): the establisher rides // the registration (the optional witness is the CF-006 test probe). producer.register(&core, ®istry, auth, None)?; ``` diff --git a/docs/architecture/rfc1928.md b/docs/architecture/rfc1928.md index bfedbf3..360b5d6 100644 --- a/docs/architecture/rfc1928.md +++ b/docs/architecture/rfc1928.md @@ -1,22 +1,22 @@ --- status: draft last_updated: 2026-09-24 (the keep-or-cut sweep + the refusal -vocabulary variant — the fork's owned post-fork changes section; -review 002 N-1/N-4; the targeted fork 2026-09-22 — ADR-013 -supersedes ADR-011's maintenance posture; the module is +vocabulary variant: the fork's owned post-fork changes section; +review 002 N-1/N-4; the targeted fork 2026-09-22, with ADR-013 +superseding ADR-011's maintenance posture; the module is `src/rfc1928/`, the fork point is `5b18d4d`, the lint-allow posture dissolves; extraction review F-1: `ReplyError::from_u8`'s panic arm 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 fork** of fast-socks5's `T`-generic subset ([ADR-013](decisions/ 013-targeted-fork.md); the extraction mechanics in [ADR-011](decisions/011-inline-vendor.md); OQ-SK-04's resolution). -This document is the fork's scope — what was extracted, what was -not, the deliberate modifications — and the maintenance posture. +This document is the fork's scope (what was extracted, what was +not, the deliberate modifications) and the maintenance posture. The protocol base decision is ADR-002; the feature gating is 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 `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). 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 -`socks4` module and the legacy `Config`/`Authentication` layer — -see the not-forked inventory). The extraction contains zero +`socks4` module and the legacy `Config`/`Authentication` layer; see +the not-forked inventory). The extraction contains zero kernel-net code, so it is wasm-clean by construction; there is no in-tree `net`-shaped feature gate. Post-extraction (ADR-013), the region is **owned code**: project 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), not for an ownership relation it no longer has. ## 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 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`/`AuthMethodSuccessState`) | 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 | | 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::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::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 | ## What was NOT Forked (the cut, re-read as the hand-port map) 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 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 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 layer was left ungated by POC #4 (it compiles without net) but no extraction consumer reached it. None of it is forked: | 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` + `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` + `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` 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/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) | **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 typestate-driving shape; `run_udp_proxy_custom` is unusable on 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 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 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 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#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 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). 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 - 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 `StandardAuthentication` enum + `allow_no_auth`, `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): `ConnectError` (constructed nowhere in-tree; the dial-error → 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 likewise kept, its display intact. - **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 refusals (the command gate + the four seam-refusal sites). Upstream 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, 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 at extraction, before the fork): @@ -154,8 +154,8 @@ at extraction, before the fork): out of RFC 1928 scope). `SocksError`'s `ReplySocks4Error` variant (also `#[cfg(feature = "socks4")]` upstream) goes with it. - The legacy `Config`/`Authentication` auth layer is out - (ungated upstream, but consumed only by the cut legacy/net region - — `Socks5Server`/`Socks5Socket`/the `run_*` handlers; the + (ungated upstream, but consumed only by the cut legacy/net region: + `Socks5Server`/`Socks5Socket`/the `run_*` handlers; 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 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 re-pointed at the crate's `tracing`. - 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` stay the wire-level vocabulary. - 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 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 there), so a misbehaving peer surfaces as a typed error, never a 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 healthy stream). `reply_success`/`reply_error` were likewise 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 - `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 type-infers the result through a generic blanket impl and suppresses 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 `#[allow]`s removed and the suppressed smells fixed (`u8::max_value()` → `u8::MAX`, needless lifetimes elided, `match_like_matches_macro` applied, `io::Error::new(Other)` → `io::Error::other`, `wrong_self_convention` fixed on `AuthMethod:: -new`, the `ToSocketAddrs` error string re-worded — it no longer -names the cut `resolve_dns()` — and the `#[allow(dead_code)]` on +new`, and the `ToSocketAddrs` error string re-worded (it no longer +names the cut `resolve_dns()`); the `#[allow(dead_code)]` on `AuthenticationMethod` swept). These are one-time, recorded for completeness; the fork is first-class code thereafter. ## Maintenance Posture - **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 preservation (ADR-013). - **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 the edge cases the forked code must keep passing; when in doubt, 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. - **Upstream posture**: fast-socks5 is third-party; nothing waits on it, nothing is absorbed from it. A future upstream fix that 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). The offering-a-PR-upstream courtesy item is retired with the vendor posture (the net gate dissolved with the kernel-net region; there is no in-tree need to offer). - **Provenance is permanent**: the module docs and the bundled 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 (ADR-013). - **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-03 (the bind seam) -- POC #4 (`poc-connect-wasm-findings.md` — the measured gate), POC - #5 (`poc-bind-findings.md` — the BIND driving + the `new_reply` +- POC #4 (`poc-connect-wasm-findings.md`: the measured gate), POC + #5 (`poc-bind-findings.md`: the BIND driving + the `new_reply` finding), POC #2 (the typestate-driving shape for ASSOCIATE) - `tasks/implementation/review-vendor-extraction.md` (the fidelity 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) \ No newline at end of file diff --git a/docs/architecture/wire.md b/docs/architecture/wire.md index cc20eb8..1560314 100644 --- a/docs/architecture/wire.md +++ b/docs/architecture/wire.md @@ -1,19 +1,19 @@ --- 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 -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) --- -# alksocks — Wire Format +# alksocks: Wire Format 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 the channel `BiStream`). The channels layer strips its own 8-byte 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 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 WHAT. RFC 1928's own wire rules (method negotiation, command encoding, 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. ## The Open Op (`channels/socks5/sub`) `Sub`-typed operation (alkcall ADR-047): the reply carries -`{ "channel_id": }` once; everything else — the entire RFC 1928 -conversation and the data plane — flows on the channel's `BiStream`, +`{ "channel_id": }` once; everything else (the entire RFC 1928 +conversation and the data plane) flows on the channel's `BiStream`, never in the call response stream. ALPN marker `alk/socks5` (ADR-001). ### 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 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 params stay valid forever; a future per-session option would be an additive schema extension (ADR-001). ### Reply / errors -- Success: `{ "channel_id": }` — the establishment phase (the +- Success: `{ "channel_id": }`: the establishment phase (the session gate) succeeded, the open handler is running. - 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` / `timeout` (alkcall ADR-049 §3). The registry's ACL gate runs - before the wrapper (`FORBIDDEN` for a scope-less identity — - POC #1). **A failed open never returns a `channel_id`** — no + before the wrapper (`FORBIDDEN` for a scope-less identity; + POC #1). **A failed open never returns a `channel_id`**: no phantom channel. Session-level refusal is always channels-level; *target-level* refusal happens later, in-stream, as an RFC reply 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 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 refused structurally; the demux-vs-phase trade is resolved by 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 association loud (`udp_undecodable_frame_ends_association`, POC #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 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 - 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 protocol-layer timed (ADR-014; ADR-007 decided the BIND quiet wait's ends the same way). A deployment enforces idle policy at its edges, not in-band. Errors are EOF-shaped (channels-level) or reply-code-shaped (in-stream, pre-data-plane). -- **One channel per SOCKS5 session** — no sub-demux, no flow key - (AGENTS.md convention 10; the ASSOCIATE handle swap makes the +- **One channel per SOCKS5 session** (no sub-demux, no flow key; + AGENTS.md convention 10; the ASSOCIATE handle swap makes the channel the relay handle, ADR-003). ## The Data Plane @@ -107,7 +107,7 @@ ADR-003/ADR-007): ### CONNECT: raw pass-through 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 sink on completion (half-close semantics fall out); EOF sentinels 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 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 same channel: - **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, both directions. **A zero-length payload is a legal empty datagram - (never EOF — the F-2 invariant, shaped on alktunnels ADR-003's - codec, re-implemented in first-party `src/wire.rs`); `len == 0` — - zero inner bytes — is unreachable from a compliant peer, since + (never EOF; the F-2 invariant, shaped on alktunnels ADR-003's + codec, re-implemented in first-party `src/wire.rs`); `len == 0` + (zero inner bytes) is unreachable from a compliant peer, since `addr` is always ≥ 7 bytes, so the operative invariant lives at 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) whenever `len(addr) + len(data) > 65535`. The UDP payload bound alone (65507) does *not* fit u16 unconditionally: a 65507-byte datagram to a 255-character domain is 65507 + 259 (max domain-form 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 for domain-form destinations, 65516 for IPv6, 65528 for IPv4. Implementations must not use unchecked u16 arithmetic on the 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 iroh-socks5 v1 posture). - **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 vocabulary (POC #2 test 4: a fresh associate succeeds immediately 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 idle-expired (ADR-014); a deployment wanting an idle bound enforces 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), with the egress readiness contract: `next_reply` is `Pending` when 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:** `Egress::next_reply` returns *inner* bytes (`addr + data`); the 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). **Per-datagram `send` is awaited inline** (the stage interleaves 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. ### 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 in-band path). - **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 control stream's EOF/garbage/error (the client-gone teardown riding the accept future's drop). - **reply#2 carries the peer address** (the real peer on both - non-sentinel variants — local: `accept()`'s peer, byte-exact; - composed: the forwarded op's `peer` param since the bind-first + non-sentinel variants: local is `accept()`'s peer, byte-exact; + composed is the forwarded op's `peer` param since the bind-first graduation, alktunnels `docs/research/tunnels-graduation.md`; sentinel `0.0.0.0:0` wrapper-aware). - **Phase 2 is raw pass-through** (`pump_bidi`, the CONNECT shape) @@ -204,7 +204,7 @@ the same channel: ### 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 teardown. CONNECT/BIND data planes end on EOF; the ASSOCIATE association ends on EOF. @@ -212,7 +212,7 @@ the same channel: whole point); pump-phase failures are EOF-shaped (alkcall ADR-049 §6). - **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 @@ -227,7 +227,7 @@ UDP ASSOCIATE (the datagram stage, one direction's view): ``` [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) len == 0 (no addr bytes): unreachable from a compliant peer EOF: the channels-level sentinel, outside the codec @@ -244,9 +244,9 @@ BIND (the phase sequence, one channel's view): ## Open Questions 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' -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 translation layer) rides the dial callback and does not touch this surface. @@ -256,7 +256,7 @@ surface. - ADR-001 (op/params), ADR-003 (the handle swap + frame), ADR-004 (the seams + phase-model refusal paths), ADR-007 (BIND), 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, refusal mapping), `poc-udp-associate-findings.md` (the stage, readiness contract, framing-once invariant, association lifetime), @@ -266,4 +266,4 @@ surface. (channel open), ADR-049 (establishment/typed errors), ADR-050 (`pump_bidi`); alktunnels ADR-003 (the length codec + F-2) - RFC 1928 (the protocol's own wire rules), iroh-socks5 eval - (`docs/research/iroh-socks5-eval.md` — the frame prior art) \ No newline at end of file + (`docs/research/iroh-socks5-eval.md`, the frame prior art) \ No newline at end of file diff --git a/docs/research/fuzzing.md b/docs/research/fuzzing.md index 653193e..f29c39e 100644 --- a/docs/research/fuzzing.md +++ b/docs/research/fuzzing.md @@ -1,52 +1,52 @@ # 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). 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). **Date:** 2026-09-28 **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 `/workspace/@alkdev/alktunnels/docs/research/fuzzing.md` (wave-1/wave-2 adopted, six harness-model findings, no crate bugs). Tooling, corpus 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. --- ## 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: 1. **alkcall's fuzz doc §5 named this crate's surfaces as downstream targets.** Its "non-surfaces (deliberately)" note ends: *the channels layer carries opaque `Bytes` and the handler owns sub-stream framing - (ADR-035) — those downstream parsers are downstream crates' fuzz - targets.* Our `src/wire.rs` datagram codec **is** that named + (ADR-035); those downstream parsers are downstream crates' fuzz + targets. Our `src/wire.rs` datagram codec **is** that named 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 duplicating it. 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 - (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 - command encoding), `read_username_password` (RFC 1929), the client's - `read_request_reply` — are fed bytes produced by **arbitrary peers** + command encoding), `read_username_password` (RFC 1929) and the client's + `read_request_reply`, are fed bytes produced by **arbitrary peers** in both directions: the producer drives the server typestate against consumer bytes, and the consumer sessions + front doors drive the 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 datapoint) lives in: hand-rolled state transitions, length arithmetic, 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 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, @@ -54,13 +54,13 @@ lines, all pointing the same way: 3. **The differential suite's blind spot is exactly fuzzing's 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: random *malformed* input exploration (the panic-on-attacker-bytes class), stateful interleavings, and resource-bound assertions under adversarial sequences. The fork is about to be published (0.1) as 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 `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 demonstration that the infallibility/no-panic contracts hold on 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 campaigns find nothing, that is the successful-negative-result posture 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. AFL optional/deferred; bolero deferred (the async-friction argument); 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). **Layout (the upstream pattern, load-bearing):** @@ -97,7 +97,7 @@ fuzz/ │ ├── datagram_codec.rs │ ├── rfc1928_codec.rs │ └── params_parse.rs -├── shared/ alksocks-fuzz-shared — STABLE-toolchain library +├── shared/ alksocks-fuzz-shared (STABLE-toolchain library) │ └── src/… invariant logic + corpus replay (§3) ├── corpus// committed hand-made seeds (§3) ├── 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 - binaries — the stable-toolchain corpus replay + binaries: the stable-toolchain corpus replay (`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME invariant functions the fuzzer runs, on stable, in the release verification checklist. This is alkcall §7.2 tier-3's deliverable 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. - **Root `Cargo.toml` needs the explicit `[workspace]` table** (`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 nightly-consumed dev-deps (the exact failure alkcall hit). - **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 `datagram_codec` target's shape, scoped to this crate's codec (the 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: - **no-panic on any byte sequence** (the baseline). - **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` / `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). - **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) always surfaces as `EmptyFrame`, never as an empty datagram. - **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 - 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). - **consumption accounting / splitting transparency**: feeding in 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. - **joint bound**: `frame` rejects iff `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 joint-bound reason the codec exists); `frame_inner` rejects iff `inner.len() > MAX_INNER_LEN`. The `checked_add` path (payload overflow wrapping the u16) rejects, never wraps. - **decoder-state bound**: `buf` + in-flight `current` stay bounded by 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` exposes this via `is_mid_frame()` + a new `retained_bytes()` 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/`) 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): 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 (`to_be_bytes` → parse → equal). - **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. - **the client reply path** (`read_request_reply`): no-panic on any 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. - **`parse_udp_request`** (the RFC UDP header codec, front doors only 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`. - **auth sub-handshakes** (`read_username_password`, RFC 1929): 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. - **`ReplyError::from_u8`** full-domain table: every `u8` maps, no 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` dispatch, the registry-spec builder). - **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 spec is a constant**: `socks5_open_spec()`'s wire-visible 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 drift guard, mirrored from the in-suite test). - **the reason surface is a pass-through extraction, not a validated 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 here as the modeled contract); `SocksEstablishmentReason::parse` never panics and is total. @@ -239,29 +239,29 @@ generator) → `Socks5Params` deserialization + `socks5_open_spec()` + ### 3.4 What is deliberately NOT a target (yet) - **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 session-level interleavings (associate stage select-shape, BIND quiet 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. -- **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` / `new_udp_header`) is covered by the §3.2 target. The rest is I/O 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). ## 4. Scope: two waves **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 diagnostic addition to the crate: `DatagramReader::retained_bytes()` (the decoder-state byte count §3.1's bound asserts against, in 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 alkcall `manager_routing` / alktunnels `session_pumps` style (live 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 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 -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 was the crate's whole surface; here the byte-level targets carry the 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 **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 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 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 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 log file; poll, never `wait`. 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 pattern-kill-and-recheck in one call. ## 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 Actions workflow files. **Corpus replay is the fuzz gate as plain `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 -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`. ## 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 (§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; 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 open-op-response-replayed scenario alkcall's changelog names is the consumer open path. Verification: full suite (default + all-features) 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, - 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, now across eleven targets). One diagnostic addition to the crate: `DatagramReader::retained_bytes()` (§3.1's bound target). Seeds committed: 45 + 65 + 19 generated (fuzz/gen_fuzz_seeds.py) + 2 campaign-found crash inputs promoted (`datagram_codec/seed-900`, - `seed-901` — the §7 harness findings below). -- **2026-09-28 — two harness-model defects caught by corpus replay + `seed-901`, the §7 harness findings below). +- **2026-09-28: two harness-model defects caught by corpus replay pre-campaign** (the alkcall §7.7 fuzzer-as-harness-checker pattern): 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 every frame; the byte-for-byte re-frame holds over the decodable ones; `EmptyFrame` is pinned as the only zero-inner-byte shape. 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 re-frame possible. 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` input schema is the gate that keeps non-object forms from ever reaching the type), but the harness must model the serde reality: the trivial-params invariant is now "an empty composite parses, everything non-empty rejects, the canonical round-trip is `{}`." -- **2026-09-28 — first campaign finding (fork-mode containment as - designed): a third harness-model defect.** The `datagram_codec` +- **2026-09-28: first campaign finding, fork-mode containment as + designed (a third harness-model defect).** The `datagram_codec` 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 committed as `seed-900`; the campaign relaunched clean. -- **2026-09-28 — second campaign finding (also harness, fork-mode - containment): the count walk's `declared >= 7` gate excluded short - domains.** Input `0005 0301 1203 03bb 00` — a domain frame with a +- **2026-09-28: second campaign finding (also harness, fork-mode + containment); the count walk's `declared >= 7` gate excluded short + 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 the walk's `declared >= 7` gate (meant to skip addr-less frames) rejected it; the per-ATYP `addr_len` computation already does that job. The gate lowered to `>= 4` (the minimum ATYP + length-byte prefix the walk reads); the crash input is committed as `seed-901`; 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 -timeout=25`; codec `-max_len=65536`, rfc1928 `-max_len=4096 -use_value_profile=1`, params `-dict=json.dict -use_value_profile=1). All three exited 0 with - empty artifact directories — no crashes, hangs, OOMs, or leaks.** - - `datagram_codec` (post-harness-fixes) — ~1.9M execs (~3k exec/s), + empty artifact directories: no crashes, hangs, OOMs, or leaks.** + - `datagram_codec` (post-harness-fixes), ~1.9M execs (~3k exec/s), 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 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 - 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 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 - 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 spec-constant and pass-through-reason invariants 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). - **NO crate bugs found by wave 1.** All three findings this session 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, alktunnels §7, here §7). The codec's contracts (infallible reassembly, the F-2 layering, the joint bound, the retained-bytes bound) and the RFC core's no-panic posture held across every adversarial input the campaigns generated. -- (progress appended as implementation proceeds — see git log) +- (progress appended as implementation proceeds; see git log) ## 8. Verification commands @@ -425,11 +425,11 @@ Campaign flags per target: `datagram_codec -max_len=65536`; runner, corpus policy, no-CI posture): `/workspace/@alkdev/alkcall/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 - This crate: ADR-001 (op/params), ADR-003 (the frame codec), ADR-013 (the targeted fork), `docs/architecture/wire.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 Fuzz Book (all linked from the upstream doc's §9) \ No newline at end of file diff --git a/docs/research/iroh-socks5-eval.md b/docs/research/iroh-socks5-eval.md index 16b153b..e74aa0e 100644 --- a/docs/research/iroh-socks5-eval.md +++ b/docs/research/iroh-socks5-eval.md @@ -3,7 +3,7 @@ status: complete 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 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 bugs are mine"). The wire codecs are clean, bounded, and unit-tested; 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 ASSOCIATE round-trip, BIND, allow-list accept/deny, greeting edge cases. 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. - **UDP header codec** (`socks5.rs` `parse_udp_packet`/ `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`. - **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 both halves (`tokio::join!` + propagate both errors). - **UDP association lifecycle**: the client side ends the association when the SOCKS5 control connection closes (`wait_until_closed` 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. - **Reply-code mapping** is faithful where it matters (refused vs unreachable distinguishes `ErrorKind::ConnectionRefused`). Weaknesses (acceptable for its scope, relevant if borrowed): -- **RFC 1929 username/password is absent** — no-auth only - (`negotiate` writes `0xFF` if the client doesn't offer no-auth). Auth +- **RFC 1929 username/password is absent** (no-auth only: + `negotiate` writes `0xFF` if the client doesn't offer no-auth). Auth is the iroh `EndpointId` allow-list at the connection level. - **BIND's second-incoming-connection shape** accepts exactly one inbound connection and has no timeout or multi-accept handling (RFC 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). - **UDP relay is one socket + flow-table-free on the server side**: all 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 per-endpoint flow table like alknet's `Socks5UdpSocket` model. - **`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` 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 binds. @@ -117,7 +117,7 @@ Weaknesses (acceptable for its scope, relevant if borrowed): + `Router::accept(ALPN, handler)` is the same posture as alkcall's `register_openable` (ALPN-scoped produced resource, handler per 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 the alk/ ALPN convention too. 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 door (`bind_listener`/`serve` is the ssh `-D` shape POC #1's `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. 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 resolver (`DnsResolver`) and a privacy rationale (DoH so the exit 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, SendStream, RecvStream}` concretely; `relay.rs` signatures take `&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 complete form (typestate, auth traits, reply-code enums, generic `T`). 2. **Not generic over the stream.** The whole reason POC #1 could drop a channels `BiStream` into fast-socks5's `Socks5ServerProtocol` with 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. 3. **RFC 1928 subset.** No username/password auth (OQ-SK-02's RFC-facing 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 implementation with a test suite covering the RFC edge cases; 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); 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: ~120 cfg lines, functional wasm-clean subset) dominates. 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 (fast-socks5's `Socks5Stream::use_stream` slot). alksocks' 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 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: - **Datagram stage rides the same stream** (its "second channel" option is not used; the association's stream carries framed `Datagram`s 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 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 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 *front door* (the local UDP socket a vanilla client sends to), where 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 prefix inside the same stream as RFC headers): the alkcall-channel 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 encode/decode exists only where a vanilla-client socket boundary exists (the optional local backends on either side). - **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' channels path (no socket), phase-0's sentinel question remains open; 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 local-backend-bridge option) would need the same real-socket 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 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 per datagram. This is *simpler* than alknet's flow-table model and 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` | | Server structure | explicit typestate + interception points | single `accept()` method, monolithic | | 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 | | 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) | -| 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 | -| 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 | ## 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 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. 2. Per-datagram addressing with no producer-side flow table suffices for the *local egress* variant (revisit alknet's flow-table posture in light of this). Caveat added 2026-09-13: under *composed* egress (per-target alktunnels udp-tunnels, the OQ-SK-03 handle-swap discussion) the flow table returns as a destination→tunnel-channel - table — a dial-callback policy, not a protocol-layer concern. -3. Control-stream EOF ends the association (`wait_until_closed` shape — + table, a dial-callback policy, not a protocol-layer concern. +3. Control-stream EOF ends the association (`wait_until_closed` shape: on channels, the channel's EOF is the same signal; no extra control vocabulary needed). 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, copyright Matthew Geddes; keep LICENSE-MIT/LICENSE-APACHE entries and a 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 third-party upstream, so nothing is "filed" there as of right; 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 null on crates.io. \ No newline at end of file diff --git a/docs/research/phase-0.md b/docs/research/phase-0.md index ffdbb7d..59384ea 100644 --- a/docs/research/phase-0.md +++ b/docs/research/phase-0.md @@ -5,7 +5,7 @@ canonical statement per decision, reasoning trails trimmed to their Phase 1 weight; no decisions changed) --- -# alksocks — Phase 0 (Exploration) +# alksocks: Phase 0 (Exploration) This document captures Phase 0 (Exploration) for the `alksocks` crate: vision, guiding principles, prior art, open questions (OQ-SK-01..NN), and @@ -18,7 +18,7 @@ specs, ADRs, and the open-questions tracker. Drafted 2026-09-12, emerging from the initial setup discussion. The crate is the sibling of alktty (`alk/tty`) and alktunnels (`alk/tunnel`) on the 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. Phase 0 converged 2026-09-14 and is POC-complete: every numbered POC on @@ -29,37 +29,37 @@ each with its inputs collected (§What Phase 0 could NOT answer). ## Vision and guiding principles **One sentence:** a SOCKS5 (RFC 1928) producer/consumer protocol crate on -alkcall channels — an arbitrary-egress proxy service that never binds a +alkcall channels: an arbitrary-egress proxy service that never binds a port unless explicitly configured to, carrying the RFC conversation inside a channels data channel like any other produced resource. The RFC 1928 implementation is the vendored fast-socks5 subset (the inline vendor, -§Layering) — the crate owns the protocol code in-tree. +§Layering); the crate owns the protocol code in-tree. **Scope (resolved 2026-09-14, post-POC): socks + channels, nothing else.** The original three-component split serves different purposes and was at risk of being conflated; two of the three are this crate: -1. **Producer half** — a side exposes the SOCKS5 service as a produced, +1. **Producer half**, a side that exposes the SOCKS5 service as a produced, ACL-scoped resource (registers openable channels; the open handler runs the RFC state machine; target dials happen there or further downstream). This is the `-D` service over channels. -2. **Consumer half** — a side opens SOCKS5 channels against a produced +2. **Consumer half**, a side that opens SOCKS5 channels against a produced service and speaks RFC 1928 inside them. *Optionally* it may expose the service locally: bind a real `socks5://127.0.0.1:port` endpoint and relay each accepted connection into a channel (the ssh `-D` local-exposure shape, and the path tun2proxy/curl/browser point at). -3. ~~**SOCKS client wrapper for QUIC runtimes** — `AsyncUdpSocket` +3. ~~**SOCKS client wrapper for QUIC runtimes**: `AsyncUdpSocket` implementations (quinn and noq) so downstream clients can route QUIC through any third-party SOCKS5 server (the iroh privacy posture: a client that doesn't want to leak its real IP to relays needs a SOCKS - hop ahead of the QUIC dial).~~ **Moved to the alknet rewrite** — it is + hop ahead of the QUIC dial).~~ **Moved to the alknet rewrite**; it is alknet's own ancestor (ADR-090 + the quinn-proxy POC live there), it neither produces nor consumes channels, and alknet is where downstream clients will actually consume it. The transfer took OQ-SK-06 and POC #3 with it. This crate's client-side story is the channels-native consumer session (POC #1) plus the wrapper-aware associate session - (POC #2) and the optional front doors — both POC-validated. + (POC #2) and the optional front doors, both POC-validated. Components 1+2 form the producer/consumer protocol pair (channels in, RFC 1928 inside). Keep them structurally separate in the crate layout. @@ -67,11 +67,11 @@ RFC 1928 inside). Keep them structurally separate in the crate layout. Guiding principles, inherited from the alk* family: 1. **"ALPN as a service," not "a server."** The SOCKS5 service is a - produced, ACL-scoped resource on a channels connection — the same + produced, ACL-scoped resource on a channels connection, the same shape as an alktty terminal or an alktunnels TCP tunnel. Kernel binds exist only as explicit, optional assembly-layer shapes on - *either side* — the producer may serve from a real listener (a - feature-gated local backend, component 1's optional shape), and the + *either side* (the producer may serve from a real listener, a + feature-gated local backend, component 1's optional shape) and the consumer may expose the resource on a local port (component 2's optional shape, the ssh `-D` front door). The protocol itself never binds; the binding decision belongs to the caller. @@ -103,9 +103,9 @@ Guiding principles, inherited from the alk* family: not code paths. 6. **The vpn-like endgame composition.** A SOCKS5 server + tun2proxy is the last step (ish) of "vpn-like without actually being a vpn": a - user tunnels a produced SOCKS5 resource to a local port (component - 2's optional shape), then points tun2proxy (`/workspace/tun2proxy` - — it takes `--proxy socks5://user:pass@host:port` natively, + user tunnels a produced SOCKS5 resource to a local port (component + 2's optional shape), then points tun2proxy (`/workspace/tun2proxy`; + it takes `--proxy socks5://user:pass@host:port` natively, `src/args.rs`) at it, and the whole host's traffic rides the alk* egress. This crate is the SOCKS5 leg of that composition; alktunnels carries the tunnel; tun2proxy closes the loop. (The udpgw framing @@ -117,88 +117,88 @@ Guiding principles, inherited from the alk* family: The foundation is POC-validated and ADR-pinned upstream; this crate does not start from zero. It inherits: -- **The channels data path** — demux→Connection→handler→mux, validated +- **The channels data path** (demux→Connection→handler→mux), validated by the alknet-channels POC and production alktty/alktunnels. The SOCKS5 payload is raw bytes inside a channels data channel's `BiStream`; channels strips its 8-byte header transparently (alknet ADR-093 / - alkcall ADR-035). SOCKS5 CONNECT is one `BiStream` per session — the + alkcall ADR-035). SOCKS5 CONNECT is one `BiStream` per session; the RFC conversation *is* the stream; no sub-demux, no framing, no flow key (one channel per SOCKS5 session). -- **The two-pump data plane** — the CONNECT proxy loop is the canonical +- **The two-pump data plane**: the CONNECT proxy loop is the canonical two-pump shape; `alkcall::channels::pump_bidi` (ADR-050) is pinned upstream and alktunnels-validated. Use it; do not hand-roll. -- **The establishment story** — session opens use +- **The establishment story**: session opens use `register_openable_with_establisher` (alkcall ADR-049 + amendments): the establisher runs as an awaited, bounded establishment phase; a refused session is a typed `channel:open_failed` call error (`reason ∈ dial_failed / unknown_resource / resource_shortage / handler_error / timeout`), never a phantom channel. The negotiate/ auth half of the RFC conversation still runs in-stream over the - `BiStream` — establishment covers only the producer's accept policy, + `BiStream`; establishment covers only the producer's accept policy, not the RFC handshake. The establisher can project reply fields (alkcall 0.8.0, `Establishment::with_reply_field(s)`, ADR-049 - amendment 3; consumable via `ChannelClient::open_channel_with_reply`) - — the carrier alktunnels' bind-first `bound` address rides; a SOCKS5 + amendment 3; consumable via `ChannelClient::open_channel_with_reply`), + the carrier alktunnels' bind-first `bound` address rides; a SOCKS5 establisher that ever needs to return open-time metadata has the same seam (additive; absent fields leave the reply byte-identical). -- **The producer/consumer model** — producer registers openable channels +- **The producer/consumer model**: producer registers openable channels (authorization for free via `AccessControl`); consumer opens them via `ChannelClient` (alkcall ADR-037, ADR-043). Connection direction is independent of service direction. -- **The relay/hub story** — a SOCKS5 resource traverses alkcall hub +- **The relay/hub story**: a SOCKS5 resource traverses alkcall hub relays like any produced resource; the hub's terminate-and-re-produce proxy applies per hop, and a SOCKS5 resource is exactly the "further downstream" shape alktunnels described. No socks5-specific relay - work. The relay machinery is in-tree — alkcall 0.8.0 ships + work. The relay machinery is in-tree; alkcall 0.8.0 ships `ChannelRelay` + `HubLegTemplate` (ADR-051), so a hub leg is an install-template composition, not assembly-layer hand-rolling; relayed replies carry extra fields through per-hop-truthfully (the bind-first `bound` flows end-to-end), and flavor-form ops (the direct op's `channels/tunnel/direct` shape) re-produce as channels, not forwarding stubs. -- **The identity seam** — alkcall's CF-005/CF-006 (0.7.0): the per-call +- **The identity seam** is alkcall's CF-005/CF-006 (0.7.0): the per-call opener identity arrives on the open-op hooks. Auth mapping (identity → SOCKS5 credentials/ACL, if any) happens there, not via in-band SOCKS auth, unless RFC-facing username/password is needed for vanilla SOCKS5 - clients (OQ-SK-02 — settled posture: split by the door). Still current - at alkcall 0.8.0 — the 0.8.0 additions (reply projection, relay) are + clients (OQ-SK-02, settled posture: split by the door). Still current + at alkcall 0.8.0; the 0.8.0 additions (reply projection, relay) are additive and do not touch it. -- **The backend inversion point pattern** — substrate-specific types +- **The backend inversion point pattern**: substrate-specific types confined to feature-gated backend modules, injected at the assembly layer (alktty `TtyBackend` / alktunnels no-trait precedent). -- **The wasm-clean default crate** — protocol-only code should compile +- **The wasm-clean default crate**: protocol-only code should compile to `wasm32-unknown-unknown` (alktty/alktunnels precedent). The extracted fast-socks5 subset is wasm-clean by construction: the inline vendor is an extraction (the `T`-generic subset only; the - kernel-net handlers are not copied — OQ-SK-04 resolved; ADR-011 + kernel-net handlers are not copied, OQ-SK-04 resolved; ADR-011 §Amendment, 2026-09-21; POC #4 verified the subset is functionally complete). -- **The upstream substrate this crate composes against** — alkcall +- **The upstream substrate this crate composes against**: alkcall 0.8.0 (reply projection; `ChannelRelay`/`HubLegTemplate` per ADR-051; flavor-form open-op ids in discovery per ADR-047 amendment 3) and alktunnels 0.2.0 (the direct op `channels/tunnel/direct`, ADR-007; the bind-first establisher + forwarded op `channels/tunnel/forwarded`, ADR-008; `TunnelListener`). The graduation (`alktunnels docs/research/tunnels-graduation.md`) took this crate's - asks as evidence and landed them in one version bump — see the + asks as evidence and landed them in one version bump; see the per-OQ notes (OQ-SK-01, OQ-SK-03, OQ-SK-08) for what changed in shape. ## Prior art -### alktunnels Phase 0 — the closest sibling +### alktunnels Phase 0: the closest sibling `/workspace/@alkdev/alktunnels/docs/research/phase-0-findings.md`. The load-bearing conclusions this crate inherits: -- **Hub-owns-the-connection model** — role follows the resource; +- **Hub-owns-the-connection model**: role follows the resource; whoever can reach the target is the producer, whoever wants the bytes is the consumer. The "exposed port" is a virtual, ACL-scoped resource, not a bind. For alksocks: the producer is the side that dials targets (the egress side); the consumer is the side that wants proxied egress. "A SOCKS5 service is the same shape [as a produced tunnel - resource], further downstream" is alktunnels' own wording — this + resource], further downstream" is alktunnels' own wording; this crate exists to make it literal. -- **`-D` composes at the assembly layer** — the original half-answer, +- **`-D` composes at the assembly layer**, the original half-answer, now with the mechanism named: a SOCKS5 server at some assembly layer is just a consumer opening channels with per-connection dynamic targets. The refinement this crate adds: the SOCKS5 *service* is @@ -212,20 +212,20 @@ load-bearing conclusions this crate inherits: SOCKS5 implementation itself applies downstream. No target allowlists in the base crate unless Phase 1 wants them (OQ-SK-05). - **The codec conclusion does NOT transfer.** alktunnels needed - length-framing only for datagram substrates; a SOCKS5 CONNECT session - is a byte stream end to end (RFC 1928 defines its own framing on the - wire) — the channel payload is pure pass-through, 0 B overhead over +length-framing only for datagram substrates; a SOCKS5 CONNECT session + is a byte stream end to end (RFC 1928 defines its own framing on the + wire); the channel payload is pure pass-through, 0 B overhead over the channels 8-byte header. UDP ASSOCIATE's datagram stage is the exception (OQ-SK-03, resolved shape below). -### fast-socks5 — the vendored implementation +### fast-socks5: the vendored implementation -`/workspace/fast-socks5` (v1.0.0, MIT — third-party upstream, not ours; +`/workspace/fast-socks5` (v1.0.0, MIT, third-party upstream and not ours; read-only reference checkout). **The posture is the inline vendor as an extraction (OQ-SK-04, resolved 2026-09-20; extraction pinned 2026-09-21, ADR-011 §Amendment): extract the `T`-generic subset into -this crate — the kernel-net handlers and the `socks4` module are not -copied — carrying the wrapper's needs in-tree.** +this crate (the kernel-net handlers and the `socks4` module are not +copied), carrying the wrapper's needs in-tree.** `/workspace/fast-socks5` remains the differential-testing reference for RFC edge cases (reply-code mapping, domain addressing, fragmentation); note the vendor point (v1.0.0) in the vendored code. @@ -238,23 +238,23 @@ Key surface points, verified: `finish_auth()` / `accept_no_auth` / `accept_password_auth` → `read_command()` → (`reply_success`, `reply_error`). The legacy `Socks5Server`/`Socks5Socket`/`Incoming` API (binds a `TcpListener`) - is deprecated — the wrapper uses the explicit API only. -- **Auth surface** — the `AuthMethod` trait (metadata: `method_id`, + is deprecated; the wrapper uses the explicit API only. +- **Auth surface**: the `AuthMethod` trait (metadata: `method_id`, `new`) + `AuthMethodSuccessState` (carry the socket back out); `StandardAuthentication` enum for NoAuth + Password with static dispatch; custom methods implement the trait. Username/password check is a closure (`accept_password_auth`). The auth *decision* is where the alkcall identity seam plugs in (OQ-SK-02). -- **Command handling is swappable** — the interception points: - - `run_tcp_proxy(proto, addr, timeout, nodelay)` — dials, replies, +- **Command handling is swappable** (the interception points): + - `run_tcp_proxy(proto, addr, timeout, nodelay)` dials, replies, then `transfer(inbound, outbound)` (a `copy_bidirectional` wrapper, itself generic). This is where a channels-native dial replaces the `tokio::net` dial: the wrapper's producer half intercepts here, dials via alktunnels/alkcall primitives (or its own dial policy), and returns the `T` back. - `run_udp_proxy_custom(proto, addr, peer_bind_ip, reply_ip, transfer)` - — the customizable UDP ASSOCIATE handler. **Caveat (verified - 2026-09-13):** the "custom" seam is narrower than it looks — it calls + is the customizable UDP ASSOCIATE handler. **Caveat (verified + 2026-09-13):** the "custom" seam is narrower than it looks: it calls `udp_bind_random_port(peer_bind_ip)` *unconditionally* and derives the reply port from that kernel socket; the wrapper can customize the relay loop (`transfer`) and the reply *IP* only. The channels @@ -263,15 +263,15 @@ Key surface points, verified: Under the inline vendor this is a change we make in-tree, not an upstream ask: make the bind optional or accept a caller-supplied socket. - - `transfer(inbound, outbound)` — plain two-pump copy; a channels + - `transfer(inbound, outbound)` is a plain two-pump copy; a channels `BiStream` is a legal `T` on either side. -- **UDP support** — `new_udp_header(target)` / `parse_udp_request(buf)` - are public and pure (no socket I/O) — the SOCKS5 UDP datagram header +- **UDP support**: `new_udp_header(target)` / `parse_udp_request(buf)` + are public and pure (no socket I/O), so the SOCKS5 UDP datagram header codec is reusable without the `Socket2`-based relay machinery. The default `run_udp_proxy` binds two kernel sockets (`Socket2`, random - ports) — the *binds are in the default handler, not in the protocol*, + ports); the *binds are in the default handler, not in the protocol*, which is exactly the seam the no-bind requirement needs. -- **Client side** — `Socks5Stream` (generic over the backing socket; +- **Client side**: `Socks5Stream` (generic over the backing socket; `use_stream` upgrades any `AsyncRead + AsyncWrite + Unpin` already connected) and `Socks5Datagram` (UDP associate; also accepts a caller-supplied socket via `use_socket`). `Socks5Stream` impls @@ -280,30 +280,30 @@ Key surface points, verified: `Socks5Stream::connect` convenience constructors dial `tokio::net::TcpStream` and are non-wasm; `use_stream` is the substrate-free path. -- **Error surface** — `ReplyError` (the RFC reply codes, `as_u8`/ +- **Error surface**: `ReplyError` (the RFC reply codes, `as_u8`/ `from_u8`) and `SocksError`; `SocksServerError` on the explicit API. Map these faithfully; never collapse a refusal into a generic error. -- **`router.rs` example** — the "conditional interception" template: a +- **`router.rs` example** shows the "conditional interception" template: a server that inspects the command/target, then decides whether to proxy, refuse, or handle in-process. Structurally the shape of the channels open handler (inspect params → dial or refuse) and of the alknet ADR-090 client (hand the established stream to quinn). - **What the vendor must add (the carried needs; re-read under the - 2026-09-21 extraction amendment — ADR-011 §Amendment):** **BIND** - (absent upstream — fast-socks5 parses `Socks5Command::TCPBind` but + 2026-09-21 extraction amendment, ADR-011 §Amendment):** **BIND** + (absent upstream; fast-socks5 parses `Socks5Command::TCPBind` but every handler refuses it, `lib.rs:384-386` / `server.rs:813-816`; the wrapper drives the typestate itself) and the publication of `new_reply` (the reply encoder is private; the BIND reply#2 writer - otherwise hand-rolls the 10-byte reply — `poc-bind-findings.md`). - The other two of the original four needs — the kernel-net feature + otherwise hand-rolls the 10-byte reply, `poc-bind-findings.md`). + The other two of the original four needs, the kernel-net feature gate (POC #4: ~120 cfg lines, wasm-clean subset functionally - complete) and the `run_udp_proxy_custom` bind seam — dissolve with + complete) and the `run_udp_proxy_custom` bind seam, dissolve with the kernel-net region, which the extraction does not copy. -### iroh-socks5 — the structural sibling (evaluated, not the base) +### iroh-socks5: the structural sibling (evaluated, not the base) `crates.io/crates/iroh-socks5` (v0.5.0, MIT OR Apache-2.0, repo: -`github.com/mattgeddes/iroh-socks5` — crates.io lacks the repository +`github.com/mattgeddes/iroh-socks5`; crates.io lacks the repository link). A ~1.3k-LoC iroh `ProtocolHandler` tunneling SOCKS5 over iroh QUIC: framed `Request`/`Reply` protocol postcard-encoded over one bidirectional stream, then raw pass-through (CONNECT/BIND) or framed @@ -311,11 +311,11 @@ bidirectional stream, then raw pass-through (CONNECT/BIND) or framed (`EndpointId`s), consumer-side local TCP front door for vanilla clients. Full evaluation: `docs/research/iroh-socks5-eval.md`. Verified: -- **Independent confirmation of POC #1's shape** — one stream per SOCKS5 +- **Independent confirmation of POC #1's shape**: one stream per SOCKS5 session, framed control phase then pass-through, RFC codec only at vanilla-client boundaries, resolution producer-side. Convergent evolution from a codebase with no alk* lineage. -- **The load-bearing OQ-SK-03 prior art** — its UDP ASSOCIATE design +- **The load-bearing OQ-SK-03 prior art**: its UDP ASSOCIATE design carries framed `{addr, data}` datagrams in-stream (no RFC UDP header on the tunnel wire; the RFC header codec applies only at the local front-door socket), needs no producer-side flow table (per-datagram @@ -328,12 +328,12 @@ Full evaluation: `docs/research/iroh-socks5-eval.md`. Verified: machinery, no reply-code enum fidelity), and the client side has no in-band session type (local front door only). Extracting a substrate-agnostic core yields ~550 LoC of codecs fast-socks5 already - provides in stricter form — the POC #1/#4 base decision stands. + provides in stricter form, so the POC #1/#4 base decision stands. -### alknet's SOCKS5 client + the quinn-proxy POC — the client-side ancestor +### alknet's SOCKS5 client + the quinn-proxy POC: the client-side ancestor `/workspace/@alkdev/alknet/crates/alknet-client/src/socks5.rs` (ADR-090) -implements `Socks5UdpSocket: quinn::AsyncUdpSocket` — a SOCKS5 UDP +implements `Socks5UdpSocket: quinn::AsyncUdpSocket`, a SOCKS5 UDP ASSOCIATE tunnel wrapped as the socket QUIC polls. The quinn-proxy POC (`/workspace/@alkdev/alknet/docs/research/quinn-quic-proxy/findings.md`) validated the approach end-to-end (5/5 clean runs) against quinn 0.11: @@ -345,26 +345,26 @@ path MTU discovery. **This work belongs to the alknet rewrite** (the 2026-09-14 scope decision, §Vision): the client wrapper is alknet's ancestor-shaped -component — it neither produces nor consumes channels, and its open +component. It neither produces nor consumes channels, and its open research (the noq trait-shape split, OQ-SK-06, §OQ-SK-06) moves to alknet's Phase 0. The alknet-side cleanup that applies there: the hand-rolled codec in `socks5.rs` inlines the greeting/auth/CONNECT/ -ASSOCIATE byte logic twice — fast-socks5's client types and +ASSOCIATE byte logic twice; fast-socks5's client types and `new_udp_header`/`parse_udp_request` supersede it. -### noq — the quinn fork the client story must also fit +### noq, the quinn fork the client story must also fit > **Transferred to the alknet rewrite (2026-09-14, the scope decision, > §Vision).** Retained as background research for *alknet's* Phase 0; > see OQ-SK-06 for the transfer rationale. -`/workspace/noq` (v1.2.0; iroh's fork of quinn — iroh depends on -`noq = "1.2.0"`). Surface verified: +`/workspace/noq` (v1.2.0; iroh's fork of quinn, which iroh depends on +as `noq = "1.2.0"`). Surface verified: - `noq::AsyncUdpSocket` exists (`noq/src/runtime/mod.rs:44`) but with a **different shape** than quinn 0.11: `create_io_poller` + `try_send` are replaced by `create_sender() -> Pin>` - with `poll_send(transmit, cx)` — a sender-object split (any number of + with `poll_send(transmit, cx)`, a sender-object split (any number of `UdpSender`s per socket, each holding its own waker). - `RecvMeta` gained fields (`interface_index`, `timestamp`) but stays default-constructible; `Transmit` is unchanged in the fields the @@ -372,7 +372,7 @@ ASSOCIATE byte logic twice — fast-socks5's client types and `segment_size`). - `Endpoint::new_with_abstract_socket` exists (`noq/src/endpoint.rs:162`) with the same doc-comment intent, but takes - `Box` rather than `Arc` — the + `Box` rather than `Arc`; the poller-removal reshuffle also changed the ownership shape. - The alknet `Socks5UdpSocket` does **not** drop in unchanged: the impl must be rewritten against `create_sender`/`poll_send`, and the @@ -383,14 +383,14 @@ ASSOCIATE byte logic twice — fast-socks5's client types and ### Anti-prior-art (what NOT to carry over) -- **The alknet client's hand-rolled SOCKS5 codec** — +- **The alknet client's hand-rolled SOCKS5 codec**: `socks5.rs` inlines the greeting/auth/CONNECT/ASSOCIATE byte logic twice (handshake for UDP, again for CONNECT). fast-socks5's client types and `new_udp_header`/`parse_udp_request` supersede it; the - vendored crate provides the codecs — no second codec. -- **`Socks5Server` (the legacy bind-based API)** — deprecated upstream; + vendored crate provides the codecs, so no second codec. +- **`Socks5Server` (the legacy bind-based API)** is deprecated upstream; never the default path here. -- **In-band SOCKS auth as the primary auth story** — alkcall's identity +- **In-band SOCKS auth as the primary auth story.** alkcall's identity seam (CF-005/CF-006) authorizes at the open-op layer. In-band RFC username/password remains available only for vanilla-client compatibility at the front door (OQ-SK-02). @@ -404,7 +404,7 @@ open-questions.md` in Phase 1. Status is stated per question; what remains open is named explicitly. The point of this document is to hold the reasoning without forcing premature decisions. -### OQ-SK-01: Producer dial policy — what dials the target? +### OQ-SK-01: Producer dial policy (what dials the target?) **Status: shape resolved empirically (POC #1, 2026-09-13); the A/B assembly-layer choice is a Phase 1 policy decision.** The dial callback @@ -420,22 +420,22 @@ accept planes. What remains open is where the callback *comes from* (the dial policy): -- **Option A: dial via alktunnels (composition)** — the SOCKS5 handler - opens an `alk/tunnel` channel naming the target, and the two-pump data - plane is tunnel-channel ↔ SOCKS5-`BiStream`. Since alktunnels 0.2.0 - (ADR-007) the dynamic-target channel exists — the direct op - (`channels/tunnel/direct`, `{substrate, target}` params, the ssh - `direct-tcpip` shape, `tunnel:direct` as a separate ACL grant) — so +- **Option A: dial via alktunnels (composition)**, in which the SOCKS5 + handler opens an `alk/tunnel` channel naming the target, and the + two-pump data plane is tunnel-channel ↔ SOCKS5-`BiStream`. Since + alktunnels 0.2.0 (ADR-007) the dynamic-target channel exists, i.e. the + direct op (`channels/tunnel/direct`, `{substrate, target}` params, the ssh + `direct-tcpip` shape, `tunnel:direct` as a separate ACL grant), so Option A no longer needs a pre-registered per-target resource: one direct-op channel per CONNECT target (what the direct op was graduated *for*). The udp substrate rides the same op (one op, both - substrates — OQ-SK-03's composed egress uses it too). -- **Option B: dial directly** — the producer dials the target itself - (`TcpStream` behind the `local` feature, or an injected dial + substrates; OQ-SK-03's composed egress uses it too). +- **Option B: dial directly** (the producer dials the target itself + via a `TcpStream` behind the `local` feature, or an injected dial callback). No alktunnels dependency; the dial policy (allowlists, routing through another alksocks hop) is the caller's callback. -- **Option C: injected dialer trait** — a `Dialer`-shaped trait with - alktunnels and local-TCP implementations behind features. The POC +- **Option C: injected dialer trait**, meaning a `Dialer`-shaped trait + with alktunnels and local-TCP implementations behind features. The POC evidence stands against the trait (two real implementations exist as *callbacks*, not trait impls; alktunnels OQ-TN-05's no-trait resolution holds). @@ -443,7 +443,7 @@ What remains open is where the callback *comes from* (the dial policy): Considerations: substrate-agnostic-by-construction (AGENTS.md convention 7) favors injection over hardwiring; the "hub terminates and re-produces" story means a hub-hop SOCKS5 resource's dialer is just -"open a channel further downstream," which is Option A's shape — so +"open a channel further downstream," which is Option A's shape, so whatever is decided must not *prevent* A when composing. The A/B choice is an assembly-layer policy question; POC #5's scope-gate finding (`socks5:bind` ≠ `socks5:open`) plus ADR-007's own rationale @@ -453,20 +453,20 @@ protocol. The dialer-trait-vs-callback half of this OQ is closed; the Phase 1 ADR picks the default provider set and the params shape for the composed path. -### OQ-SK-02: Auth mapping — identity seam vs in-band SOCKS auth +### OQ-SK-02: Auth mapping (identity seam vs in-band SOCKS auth) **Status: settled (2026-09-13): "both," split by the door.** The open-op hooks receive the per-call opener identity (alkcall CF-005/ CF-006); the RFC conversation also carries its own optional username/password auth (RFC 1929). The settled posture: both mechanisms -are real, each on its own side of the door — the consumer's optional +are real, each on its own side of the door. The consumer's optional front door (component 2's ssh `-D` shape, where the vanilla world meets -the ACL world; the vpn-like endgame composition points tun2proxy's -`user:pass` at it). +the ACL world) is the side the vpn-like endgame composition points +tun2proxy's `user:pass` at. - **Producer side: identity only.** The channels-native open is authorized by the alkcall ACL/identity seam (CF-005/CF-006); a - producer-side password is moot — the ACL system is strictly better + producer-side password is moot; the ACL system is strictly better than a shared secret, and the producer's in-band conversation runs NoAuth/no-op (fast-socks5's `skip_auth_this_is_not_rfc_compliant`, or a no-op NoAuth method). A password there would add nothing the @@ -475,40 +475,40 @@ the ACL world; the vpn-like endgame composition points tun2proxy's need it.** The door owns credential presentation: a vanilla client carrying `user:pass` (tun2proxy, curl) gets a real RFC 1929 conversation at the door. The door then maps the accepted credential - to *local* configuration — which channel identity/endpoint to open - the producer channel with — and that identity reaches the producer + to *local* configuration (which channel identity/endpoint to open + the producer channel with), and that identity reaches the producer via the CF-005/CF-006 seam. The SOCKS password never crosses the channel as a SOCKS password; the channel identity never reaches the vanilla client. The "replace or second-gate" sub-question dissolves: the mechanisms occupy different planes (in-band creds are door-local; identity is -channel-level). No fast-socks5 `AuthMethod` impl is needed — the door +channel-level). No fast-socks5 `AuthMethod` impl is needed; the door checks credentials itself before handing the stream to the typestate (the door is this crate's code; the trait was designed for embedders who don't own the accept loop). The NoAuth door (POC #1's raw-pipe shape, zero protocol translation) remains valid for loopback-only or wrapper-aware use; the cred-bearing door is the same bridge plus the greeting half (fast-socks5's server typestate for auth + `read_command`, -then the consumer session in-band) — an assembly-layer choice. +then the consumer session in-band) is an assembly-layer choice. Remaining for Phase 1: the door's credential→identity mapping shape (static config vs pluggable lookup); whether the producer-side -optional local backend offers the same door surface (same code shape — -door = vanilla↔ACL boundary — so presumably yes). +optional local backend offers the same door surface (same code shape, +door = vanilla↔ACL boundary, so presumably yes). -### OQ-SK-03: UDP ASSOCIATE without binding — the hard case +### OQ-SK-03: UDP ASSOCIATE without binding (the hard case) **Status: baseline settled (2026-09-13, the handle swap) and -POC-validated end to end (POC #2, 2026-09-14 — +POC-validated end to end (POC #2, 2026-09-14, `poc-udp-associate-findings.md`).** The datagram-framing ADR (the one-way door) is Phase 1's, with the first consumer in sight; the framing has empirical ground under it. -**The handle swap — "tell the client about a UDP port" → "tell the -client about the UDP tunnel."** The design's load-bearing reading of +**The handle swap ("tell the client about a UDP port" → "tell the +client about the UDP tunnel").** The design's load-bearing reading of RFC 1928: the ASSOCIATE reply's `BND.ADDR`/`BND.PORT` is *just the -handle for the relay*, and one relay serves the whole association — the +handle for the relay*, and one relay serves the whole association: the per-datagram destination is carried in the datagram header (ATYP/addr/ port), not in the handle. The relay port was never "a port that goes to one place"; it is a multiplexing endpoint. So swapping the handle @@ -519,9 +519,9 @@ preserves every RFC property: - per-datagram destinations → `{addr, data}` frames in-band (the iroh-socks5 cut: boundary-preserving, no RFC header on the tunnel wire, no producer-side flow table; RFC codec at front doors only). - The length prefix is u16 BE — 65507 (the max UDP payload) < 65535, - so u16 suffices; boundary preservation is mandatory (empty datagram - ≠ EOF — alktunnels F-2's lesson transfers directly; the frame shape + The length prefix is u16 BE, and 65507 (the max UDP payload) < 65535, + so u16 suffices; boundary preservation is mandatory (an empty datagram + ≠ EOF; alktunnels F-2's lesson transfers directly, and the frame shape meets it structurally); - lifetime tied to the control connection → channel EOF; - "bind locally if you want" → the optional front door on either side @@ -529,42 +529,42 @@ preserves every RFC property: boundary only). Vanilla clients still literally `sendto()` the replied address, so the -in-band path is wrapper-aware-only by RFC necessity — the front-door +in-band path is wrapper-aware-only by RFC necessity; the front-door bridge is where a real socket address exists. The reply's `BND.ADDR`/ `BND.PORT` on the in-band path are pure RFC-compatibility surface (the POC used the sentinel `127.0.0.1:0`); only the front door reports a real address. -**Producer egress — two variants (the "no ports" story has two +**Producer egress, two variants (the "no ports" story has two halves).** The handle swap settles the *consumer-facing* half. The *producing-side* half is: after `{addr, data}` frames exit the channel, what gets each datagram to `addr`? Both are dial-callback policy (OQ-SK-01's shape); the protocol layer sees neither. -1. **Local egress** — the handler binds a real `UdpSocket` and +1. **Local egress**: the handler binds a real `UdpSocket` and `send_to`s per datagram (iroh-socks5's `relay_udp_server` behind this crate's `local` feature). No flow table, native-only. POC #2 implemented it (`LocalEgress`); domain-form destinations resolve at - the egress (producer-side DNS — the ssh `-D` semantic, + the egress (producer-side DNS, the ssh `-D` semantic, POC-resolved as "the egress owns resolution"). -2. **Composed egress** — the dial callback opens an alktunnels +2. **Composed egress**: the dial callback opens an alktunnels udp-substrate channel per destination: a lazy destination→tunnel table (open on first datagram, LRU-evict; POC #2 implemented it as - `ComposedEgress`). The flow table is *reborn as channel handles* — + `ComposedEgress`). The flow table is *reborn as channel handles*, because alktunnels' udp substrate is **connected** (one tunnel, one fixed target) while ASSOCIATE names a different destination per datagram. The composed form's vehicle since alktunnels 0.2.0 (ADR-007) - is the **direct op** — `open_direct` + `Substrate::Udp` dials a real + is the **direct op**: `open_direct` + `Substrate::Udp` dials a real UDP target through the dynamic-target op (`channels/tunnel/direct`), pinned by alktunnels' own test (a real UDP echo through the establisher path); the table entries are direct-op channels, and the per-destination ACL question shifts to the `tunnel:direct` grant + the dialer's target policy (the ACL governs the capability, the dial callback governs per-destination checks). Cost: one open per - destination, measured ~0.8 ms (POC #2, N=20 loopback) — fine for DNS - + browsing; the 256-channel default cap bounds fan-out (OQ-SK-09). - Benefit: **per-target ACL for free** — dissolving most of OQ-SK-05 - for the composed path — and hub relaying is per-target + destination, measured ~0.8 ms (POC #2, N=20 loopback), which is fine + for DNS + browsing; the 256-channel default cap bounds fan-out (OQ-SK-09). + Benefit: **per-target ACL for free** (dissolving most of OQ-SK-05 + for the composed path); hub relaying is per-target terminate-and-re-produce. **No upstream ask exists here:** alktunnels' connected-udp substrate is @@ -572,38 +572,38 @@ correct as-is for per-target composition; an "unconnected arbitrary-egress udp resource" upstream would just be SOCKS5 again with different framing (circular). -**Phase model vs alktty-style demux (the extensibility trade — Phase 1 +**Phase model vs alktty-style demux (the extensibility trade, a Phase 1 ADR input).** An alternative shape surfaced: alktty's logical demux (input/output/error/control sub-streams inside one channel, type byte per chunk) applied to the SOCKS5 channel. Analysis against RFC 1928's actual structure: **RFC 1928 is one command per connection, and control and data never interleave in either direction.** CONNECT's control conversation ends at the reply, after which the channel is a pure byte -stream; ASSOCIATE's control stream goes silent after the reply — +stream; ASSOCIATE's control stream goes silent after the reply; fast-socks5's own `wait_on_tcp` (`src/server.rs:1195`) treats any post-reply control byte as protocol garbage (`UnexpectedUdpControlGarbage`); the phase model needs no mode byte -(the reply itself synchronizes the switch — POC #2 confirmed post-reply +(the reply itself synchronizes the switch; POC #2 confirmed post-reply garbage is refused structurally). So the per-chunk type byte demuxes a problem SOCKS5 doesn't have. What the demux genuinely buys: an -**additive in-band vocabulary** — new stream types extend the wire +**additive in-band vocabulary**: new stream types extend the wire additively (no format break), whereas adding a post-reply phase later is a wire break. alktunnels accepted the opposite ("no in-band control -path, ever — the escape hatch is a new ALPN, a wire change is not"); +path, ever; the escape hatch is a new ALPN, a wire change is not"); the demux reintroduces that vocabulary at 2 B/chunk (type byte + length prefix) on every datagram. The one-way-door cost is real either way: demux bakes the vocabulary in before it has a user; phase model keeps 0 B overhead on CONNECT and 2 B on ASSOCIATE datagrams. Evidence after -both POCs favors the phase model — no in-band control vocabulary was +both POCs favors the phase model: no in-band control vocabulary was needed. Decide via ADR with the first consumer in sight. -### OQ-SK-04: fast-socks5 wasm posture — RESOLVED: inline vendor +### OQ-SK-04: fast-socks5 wasm posture (RESOLVED: inline vendor) **RESOLVED 2026-09-20: inline vendor (decided).** Two facts settled it: -(1) the ownership premise was wrong — we do *not* own fast-socks5 -(third-party upstream), so the "file asks / PR" legs of the old +(1) the ownership premise was wrong (we do *not* own fast-socks5, +third-party upstream), so the "file asks / PR" legs of the old decision tree lose their cheap path; and (2) the wrapper's needs go -beyond the wasm gate regardless — BIND is absent upstream (POC #5 +beyond the wasm gate regardless: BIND is absent upstream (POC #5 proved the wrapper must drive it itself), and the two seam fixes (`run_udp_proxy_custom`'s unconditional bind; private `new_reply`) were only "asks" under the ownership assumption. The change set (net-gate + @@ -617,50 +617,50 @@ The decision tree (2026-09-12, both legs written under the wrong ownership premise "we own upstream"): - **Case 1: wasm matters for this crate → fork-first.** Make the - minimal fork and use it — offer it upstream as a PR (merge if they + minimal fork and use it, then offer it upstream as a PR (merge if they want it; we carry the fork regardless; never wait on approval). Concretely "fork" means **vendoring the relevant subset into this - crate** (the codecs, typestate machinery, client `use_stream` path — - the parts that are already `T`-generic) and gating the native-net + crate** (the codecs, typestate machinery, the client `use_stream` + path, the parts that are already `T`-generic) and gating the native-net pieces behind the crate's own `local` feature. (The vendoring shape - is what was adopted — the PR is optional courtesy, and "shrinks to a + is what was adopted; the PR is optional courtesy, and "shrinks to a plain dependency" is moot since the vendor also carries BIND + the seam fixes upstream lacks.) - **Case 2: wasm doesn't matter → plain dependency, no fork.** Ride the - `router.rs` native path exactly as-is (the alkhttp precedent — a + `router.rs` native path exactly as-is (the alkhttp precedent, a crate in this suite can ship native-only). Strictly better *if its - premise holds* — a wrap-only relationship with no carried changes. + premise holds*: a wrap-only relationship with no carried changes. (Superseded: the premise was falsified by the BIND + seam needs.) The root question was whether wasm makes sense for alksocks; the analysis on file: what wasm would serve is the protocol layer -(components 1+2) in a sandboxed adapter — the alktty/alktunnels posture +(components 1+2) in a sandboxed adapter, matching the alktty/alktunnels posture that a wasm-compiled protocol crate is the protocol layer for downstream TS/Python adapters; the natural wasm shape is a **consumer** -(speak RFC 1928 client-side over a channels `BiStream` — pure byte -framing; no local sockets needed client-side). A wasm **producer** is -odd standalone (no kernel egress) but composes — its dialer can open +(speak RFC 1928 client-side over a channels `BiStream`: +pure byte framing, no local sockets needed client-side). A wasm **producer** is +odd standalone (no kernel egress) but composes, since its dialer can open alktunnels channels further downstream (the hub story). What wasm would -not serve: the quinn/noq client wrapper (since transferred to alknet) — +not serve: the quinn/noq client wrapper (since transferred to alknet), native-only in practice. The alkhttp contrast, honestly weighed: alkhttp's substance is socket-native so nobody misses wasm there; -alksocks differs structurally — its protocol substance *is* byte-framing -over a generic `T` — but "costs nothing under the fork" is only an +alksocks differs structurally (its protocol substance *is* byte-framing +over a generic `T`), but "costs nothing under the fork" is only an argument once Case 1 is chosen; the use case is a planned sandboxed SOCKS consumer. (Retrospective, 2026-09-20: the ownership correction -resolved the OQ ahead of the adapter story — the vendor is justified by +resolved the OQ ahead of the adapter story; the vendor is justified by BIND + the seam fixes alone. The sandboxed-wasm-consumer adapter story remains a product input for how much wasm surface to exercise, not a gate.) ### OQ-SK-05: Target policy and egress scoping -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). alktunnels resolved that whatever ACL governs the socks5 resource governs everything reachable through it; OQ-SK-01's dial policy is the mechanism. Residual questions for Phase 1 (note: for the *composed* UDP -egress path, per-target ACL comes free — each destination rides its own +egress path, per-target ACL comes free: each destination rides its own direct-op channel governed by the tunnel grant's ACL, OQ-SK-03 variant 2): @@ -671,7 +671,7 @@ direct-op channel governed by the tunnel grant's ACL, OQ-SK-03 variant evidence-backed); the allowlist-hook shape makes a common policy declarative. - Is there a `SOCKS5_OPEN_SCOPE` (scope-gate on the open op, alktty - `TTY_OPEN_SCOPE` shape) — presumably yes, but the exact scope-string + `TTY_OPEN_SCOPE` shape)? Presumably yes, but the exact scope-string convention should follow alkcall's registry conventions. POC-validated in shape (`socks5:open`, `socks5:bind`); the convention ADR is Phase 1's. @@ -680,24 +680,24 @@ direct-op channel governed by the tunnel grant's ACL, OQ-SK-03 variant dialer unresolved? POC #2 resolved the local-egress half ("the egress owns resolution"); the general dialer-pass-through posture is the remaining Phase 1 choice (wasm producers may not have a resolver at - all — an interaction with the vendored crate's wasm posture, + all), an interaction with the vendored crate's wasm posture, OQ-SK-04 resolved 2026-09-20). **Virtual address space / identity-scoped subnets (added 2026-09-14, post-POC #2).** A proposal surfaced during the findings review: give identities scoped virtual subnets (e.g. 10.x.0.0/16 slices) so proxied -resources become IP-addressable — "BND.ADDR ↔ channel id" translation, +resources become IP-addressable: "BND.ADDR ↔ channel id" translation, a hub exposing proxied resources per identity under ACL. The goal is adoptable; the mechanism needs **no wire change**: DST.ADDR and BND.ADDR are arbitrary RFC fields, so virtual addresses ride ordinary -CONNECT/BIND requests — the translation layer is the dial callback +CONNECT/BIND requests; the translation layer is the dial callback (OQ-SK-01's shape: `dial(target)` matches a virtual subnet against a per-identity resource table), per-identity scoping is resources + ACL + target policy (this OQ), and hub exposure is the terminate-and-re-produce story with no new wire vocabulary. BND.ADDR stays per the handle swap (OQ-SK-03): in-band the handle is the channel (compat surface only); a real network-B address exists wherever a local -backend binds — the listener's `local_addr` (OQ-SK-08 variant 2, the +backend binds, the listener's `local_addr` (OQ-SK-08 variant 2, the bind-first `bound` reply field). The one place a virtual subnet becomes *routable from vanilla apps* is the full-tunnel composition: the host routes the subnet into the proxy (tun2proxy), so @@ -707,16 +707,16 @@ prior art before Phase 1 decides whether the crate ships a translation-table helper or leaves it entirely to assembly-layer policy. -### OQ-SK-06: noq client support — RESOLVED by transfer +### OQ-SK-06: noq client support (RESOLVED by transfer) **Resolved by the scope decision (2026-09-14, §Vision): transferred to the alknet rewrite.** The client wrapper is alknet's ancestor-shaped component (ADR-090 + quinn-proxy POC already live there), consumes no -channels, and has no consumers planned inside this crate — so its +channels, and has no consumers planned inside this crate, so its research questions (the noq trait-shape comparison: `create_sender() -> Pin>` replacing `create_io_poller` + `try_send`, the `Box` constructor ownership change; the dependency -posture — noq is consumed from the n0 workspace/git, not crates.io; the +posture (noq is consumed from the n0 workspace/git, not crates.io); the ECN/MTU carry-over) move to alknet's Phase 0 wholesale. This crate's client-side story is complete without it: the channels-native consumer session is POC-validated (POC #1's `use_stream` over a `BiStream`, POC @@ -727,15 +727,15 @@ crate's register accordingly. The noq surface notes are retained under ### OQ-SK-07: POC register (closed) **All POCs resolved; the register is closed for this crate's Phase 0.** -Numbering is historical (do not renumber — findings files and commit +Numbering is historical (do not renumber; findings files and commit messages reference these numbers): | # | What | Status | Where | |---|------|--------|-------| | 1 | Channels-native SOCKS5 CONNECT | **Passed 2026-09-13** | `poc-connect-wasm-findings.md` | | 2 | UDP ASSOCIATE (handle-swap shape) | **Passed 2026-09-14** | `poc-udp-associate-findings.md` | -| 3 | noq `AsyncUdpSocket` impl | **Dropped 2026-09-14** — transferred to alknet with OQ-SK-06 | — | -| 4 | fast-socks5 wasm fork-minimality | **Passed 2026-09-13** — folded into #1's worktree, shares #1's findings file | `poc-connect-wasm-findings.md` §POC #4 | +| 3 | noq `AsyncUdpSocket` impl | **Dropped 2026-09-14**, transferred to alknet with OQ-SK-06 | (none) | +| 4 | fast-socks5 wasm fork-minimality | **Passed 2026-09-13**, folded into #1's worktree, shares #1's findings file | `poc-connect-wasm-findings.md` §POC #4 | | 5 | BIND (accept-side command) | **Passed 2026-09-15** | `poc-bind-findings.md` | Highlights (details in the findings files): @@ -754,7 +754,7 @@ Highlights (details in the findings files): front-door RFC-codec composition) and produced the load-bearing API findings: the datagram stage is `select!`-driven with the egress readiness contract (`next_reply` must be `Pending` when no reply is - ready — an instantly-ready `Ok(None)` starves a biased select into + ready; an instantly-ready `Ok(None)` starves a biased select into livelock), framing applied exactly once by the wire layer (inner bytes at the egress seam), and the egress/session ownership must ride the table entry (teardown-at-birth). @@ -762,7 +762,7 @@ Highlights (details in the findings files): variants; the `socks5:bind` scope gate; failure mapping RFC-faithful; the vanilla front door for BIND), pinned the composed-variant sequencing against alktunnels 0.1.0 (superseded in shape by the - bind-first graduation — see OQ-SK-08), and surfaced the + bind-first graduation; see OQ-SK-08), and surfaced the ownership-rides-the-handle lesson as the third witness of the teardown-at-birth pattern. @@ -773,7 +773,7 @@ standalone crate in the global workspace with findings written into `docs/research/` here. Findings always land in `docs/research/` regardless of where the code lives. -### OQ-SK-08: BIND — the accept-side command +### OQ-SK-08: BIND, the accept-side command **Status: use case found, POC-validated (POC #5, 2026-09-15); the composed path composes against alktunnels 0.2.0 (the graduation @@ -782,31 +782,31 @@ accept variant are Phase 1 ADRs.** **The gap (fast-socks5):** `Socks5Command::TCPBind` is parsed (`lib.rs:91`) but every -handler refuses it (`lib.rs:384-386`, `server.rs:813-816` — +handler refuses it (`lib.rs:384-386`, `server.rs:813-816`, with `CommandNotSupported`); no bind machinery exists. The typestate flow (`read_command()` returning `cmd` + `TargetAddr`) means the wrapper -drives the command dispatch itself — implementing BIND is wrapper-side +drives the command dispatch itself, so implementing BIND is wrapper-side code, carried in the vendor (no upstream ask). POC-confirmed: `reply_success`'s `sock_addr` parameter carries reply#1's `BND.ADDR` verbatim; the courtesy item is publishing `new_reply` (rides the -vendor — see §Prior art's carried needs). +vendor; see §Prior art's carried needs). -**The shape (convergent precedent: iroh-socks5's channels-native BIND — -server relays raw bytes post-reply for CONNECT/BIND, `docs/research/ -iroh-socks5-eval.md`):** one channel per BIND -session, phase model — request → reply#1 carrying `BND.ADDR` → quiet +**The shape (convergent precedent: iroh-socks5's channels-native BIND, +where the server relays raw bytes post-reply for CONNECT/BIND, see +`docs/research/iroh-socks5-eval.md`):** one channel per BIND +session, phase model: request → reply#1 carrying `BND.ADDR` → quiet wait (any control byte after reply#1 is protocol garbage, same as ASSOCIATE's post-reply stage) → exactly one inbound accept → reply#2 carrying the peer address → raw pass-through data plane (zero per-packet headers in-band, same as CONNECT; only the ASSOCIATE datagram stage is framed). RFC-refusal of BIND remains valid for old consumers; support is purely additive (same ALPN, same params, no -framing change — no one-way-door exposure). +framing change, no one-way-door exposure). -**The mechanism — accept-side resources (the `-R` far-side listener +**The mechanism is accept-side resources (the `-R` far-side listener shape).** The consumer's `-R` path in alktunnels proves accept-side resources exist without forced binds; SOCKS5 BIND is their mirror -image: the producer-side listener (network B — the app server must +image: the producer-side listener (network B, the app server must reach it) is the accept resource, each popped accept two-pumps against the SOCKS5 `BiStream`. The phase-0 hand-off note ("a producer-side BIND wants the accept-side shape, not a dial") predicted exactly this; the @@ -814,31 +814,31 @@ composition supplies the use case it lacked. **Accept variants (mirror of OQ-SK-03's egress variants):** -1. **Local backend binds for real** (feature-gated `local`) — the +1. **Local backend binds for real** (feature-gated `local`), the faithful RFC shape, iroh-socks5-faithful. A real network-B address goes in reply#1, vanilla front-door clients work; FTP active mode is the canonical use and the reason vanilla client libraries speak BIND at all. No upstream ask. -2. **Composed (alktunnels listen resource)** — the listener lives on a +2. **Composed (alktunnels listen resource)**: the listener lives on a listen resource (per-listener ACL for free, hub-relayable per the terminate-and-re-produce story). **Superseded in shape (2026-09-20): - the ask ledger landed via the bind-first/forwarded graduation** — + the ask ledger landed via the bind-first/forwarded graduation**; see below. -3. **Wrapper-aware only** — zeros in reply#1, reply#2 in-band; +3. **Wrapper-aware only**: zeros in reply#1, reply#2 in-band; simplest, but vanilla clients lose BIND and the use case mostly evaporates. Retained for completeness; weak hunch against. -**POC #5's resolution (2026-09-15 — `poc-bind-findings.md`):** variants +**POC #5's resolution (2026-09-15, `poc-bind-findings.md`):** variants 1 and 2 both implemented and passing. The hunch (variant 1 as the faithful + zero-ask default) was right. Against alktunnels 0.1.0 the -composed variant needed a pinned workaround — **reply#1 first, the -tunnel open rides the accept future** (the accept-first establisher +composed variant needed a pinned workaround, **reply#1 first, the +tunnel open riding the accept future** (the accept-first establisher could not open before reply#1 without deadlocking into the -establishment timeout) — plus a two-item ask ledger (learn the bound +establishment timeout), plus a two-item ask ledger (learn the bound address without consuming an accept; peer metadata on accepted handles). -**The graduation landed — superseded in shape (2026-09-20, alkcall +**The graduation landed, superseding the shape (2026-09-20, alkcall 0.8.0 + alktunnels 0.2.0).** The graduation (alktunnels `docs/research/tunnels-graduation.md` → ADRs 007/008) took the ledger's *two asks* as evidence and adopted the ssh `forwarded-tcpip`-shaped @@ -847,7 +847,7 @@ alternative over the per-ask carriers: - **Ask #1 (learn the bound address without consuming an accept) is dissolved, not implemented.** The bind-first establisher (`bindfirst_establisher`, ADR-008 §1) binds during the establishment - phase — fast, no app-server dependency — so the open-op reply + phase, which is fast and app-server-independent, so the open-op reply natively carries the real OS-chosen bound address (the `bound` reply field, projected via alkcall 0.8.0's `Establishment::with_reply_field`, ADR-049 amendment 3; consumed via @@ -863,7 +863,7 @@ alternative over the per-ask carriers: accept-as-open shape. - **Ask #2 (peer metadata on accepted handles) landed at the wire's 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; the peer address rides the open-op params (deny-unknown, schema-gated) rather than a `TargetHandle` field; `ForwardedOpener` + `make_forwarded_op_handler` are the serving side. Correlation is a @@ -872,7 +872,7 @@ alternative over the per-ask carriers: channel-ID key. Consequence for variant 2: the composed BIND path is now *fully -vanilla-fidelity* with no interim stand-ins — the factory no longer +vanilla-fidelity* with no interim stand-ins: the factory no longer carries an expected `bind_addr`, reply#1 advertises the real `bound`, reply#2 carries the real peer, and no establishment bound threatens long BIND waits. POC #5's findings remain accurate as the record of @@ -880,7 +880,7 @@ what was validated against alktunnels 0.1.0; the Phase 1 shape composes against 0.2.0 (`register_tunnel_bindfirst_openable` + `BindFirstInjections`, or `TunnelListener` on the opener side). -**Security boundary note:** BIND is an *inbound* listener — the +**Security boundary note:** BIND is an *inbound* listener, the accept-side twin of arbitrary egress. Whatever ACL governs the socks5 resource now also governs who can mint listeners on the producer's network; per-listener policy (address/port ranges allowed to bind) is @@ -901,7 +901,7 @@ fan-out opens one CONNECT channel per flow; composed UDP egress spends a downstream channel per destination; BIND spends an accept resource per listener). -- **No wire change** — rejected permanently: the caps are policy knobs +- **No wire change** (rejected permanently): the caps are policy knobs (configurable without a wire change; the `u32` channel-ID wire space is ~4 billion). The resolution is config, not wire. - **Assembly-layer config** is the mechanism (ADR-040/041): a @@ -909,14 +909,15 @@ per listener). the default stays conservative for everyone else. - **Documented proxy-workload default** for Phase 1: the crate docs should recommend a raised cap (and its memory arithmetic: a session - is bounded + predictable — CONNECT/ASSOCIATE are bounded-buffer - pumps, BIND adds one listener + one accept) so deployments don't + is bounded + predictable, since CONNECT/ASSOCIATE are bounded-buffer + pumps and BIND adds one listener + one accept) so deployments don't discover the cap in production. - **Defense-in-depth posture:** the cap's rationales both still apply - under SOCKS5. Per-connection memory bound — arguably *weaker* here, - since the three command shapes are well-known and bounded (a - session's memory is predictable); per-identity DoS brake — arguably - *stronger* here, since each SOCKS5 session maps to real producer-side + under SOCKS5. The per-connection memory bound is arguably *weaker* + here, since the three command shapes are well-known and bounded (a + session's memory is predictable); the per-identity DoS brake is + arguably *stronger* here, since each SOCKS5 session maps to real + producer-side resources (a dial, a downstream channel, an accept listener) and the cap is the brake on one identity opening thousands. Layered defense: ACL → per-connection cap → per-identity cap → dial/accept policy; @@ -924,7 +925,7 @@ per listener). **Considered and rejected: in-channel port multiplexing.** A related proposal (2026-09-14 review) would reframe the cap by multiplexing -"ports" inside one channel — a `[port: u16][len][payload]` per-chunk +"ports" inside one channel: a `[port: u16][len][payload]` per-chunk prefix (channel-id-as-IP, port-as-port; modeled on alktty's wire). Rejected on the established grounds: it re-opens the demux-vs-phase trade OQ-SK-03 already resolved (phase model, no in-band control @@ -935,28 +936,28 @@ as unstructured, and it violates the one-channel-per-SOCKS5-session convention (AGENTS.md #10). Channel-ID economy is not a wire problem the u32 space + policy knobs fail at. **Revisit trigger:** if a deployment hits a fixed upstream channel budget that config cannot -raise, the additive escape is a *new multiplexed ALPN* — never a change +raise, the additive escape is a *new multiplexed ALPN*, never a change to this ALPN's framing. -**The related idea that survives — virtual address space / -identity-scoped subnets** — is recorded under OQ-SK-05: BND.ADDR/ +**The related idea that survives (virtual address space / +identity-scoped subnets) is recorded under OQ-SK-05:** BND.ADDR/ DST.ADDR translation via the dial callback, no wire change. ## Survey / prior-art list Candidate reading for the research specialist (to be expanded): -- RFC 1928 (SOCKS5) — the fixed protocol: method negotiation, CONNECT, +- RFC 1928 (SOCKS5), the fixed protocol: method negotiation, CONNECT, BIND, UDP ASSOCIATE, reply codes. RFC 1929 (username/password). -- fast-socks5 `/workspace/fast-socks5` — `src/server.rs` (typestate +- fast-socks5 (`/workspace/fast-socks5`): `src/server.rs` (typestate API, interception points, auth traits), `src/client.rs` (`Socks5Stream`/`Socks5Datagram`, `use_stream`), `src/lib.rs` (`new_udp_header`/`parse_udp_request`, `ReplyError`), `examples/router.rs` (conditional interception), `examples/ - custom_auth_server.rs`. (Third-party upstream; the inline vendor — - OQ-SK-04, resolved 2026-09-20 — vendors the `T`-generic subset into + custom_auth_server.rs`. (Third-party upstream; the inline vendor, + OQ-SK-04 resolved 2026-09-20, vendors the `T`-generic subset into this crate; this checkout is the differential-testing reference.) -- iroh-socks5 — `crates.io/crates/iroh-socks5` v0.5.0, repo +- iroh-socks5: `crates.io/crates/iroh-socks5` v0.5.0, repo `github.com/mattgeddes/iroh-socks5` (crates.io has no repository link; the published source matches). Evaluated in `docs/research/iroh-socks5-eval.md`: not the protocol base (iroh @@ -964,99 +965,100 @@ Candidate reading for the research specialist (to be expanded): `{addr, data}` in-band datagrams, no flow table, control-EOF association lifetime) and an independent confirmation of POC #1's one-stream-per-session shape. -- alktunnels — `docs/research/phase-0-findings.md` (the `-D` +- alktunnels: `docs/research/phase-0-findings.md` (the `-D` composition conclusion, hub-owns-the-connection, the UDP codec decision + F-2), `docs/research/tunnels-graduation.md` (the direct - op + bind-first/forwarded graduation — the substrate this crate + op + bind-first/forwarded graduation, the substrate this crate composes against), `docs/architecture/` (params/ALPN/ACL ADR template), POC summaries (POC placement and findings conventions). -- alkcall — `docs/architecture/decisions/` ADR-037/039/047/049/050/051 +- alkcall: `docs/architecture/decisions/` ADR-037/039/047/049/050/051 (channel ops, params-is-ALPN-specific, establishment, pump_bidi, reply projection, hub relay), ledger CF-005/CF-006 (identity seam), `src/channels/operations.rs` (`ChannelCore`, `OpenHandler`, `Establishment`, `ChannelPlan`), `src/channels/pump.rs`. -- alknet — `crates/alknet-client/src/socks5.rs` (ADR-090, the client +- alknet: `crates/alknet-client/src/socks5.rs` (ADR-090, the client ancestor), `docs/research/quinn-quic-proxy/findings.md` (the POC findings: trait + constructor surface, ECN/MTU limitations, BotBrowser production precedent). -- noq — `/workspace/noq`: `noq/src/runtime/mod.rs` (`AsyncUdpSocket`, +- noq (`/workspace/noq`): `noq/src/runtime/mod.rs` (`AsyncUdpSocket`, `UdpSender`), `noq/src/endpoint.rs` (`new_with_abstract_socket`), `noq-udp/src/lib.rs` (`RecvMeta`, `Transmit`), `Cargo.toml` (workspace versioning / publication posture). (Research transferred to the - alknet rewrite with OQ-SK-06 — retained here as alknet-facing + alknet rewrite with OQ-SK-06, retained here as alknet-facing background.) -- alktty — backend inversion point (`TtyBackend`), `TTY_OPEN_SCOPE` +- alktty: backend inversion point (`TtyBackend`), `TTY_OPEN_SCOPE` scope-gating shape, feature-gated `local` backend, wasm-clean default-crate verification commands. -- tun2proxy — `/workspace/tun2proxy` `src/udpgw.rs` and its SOCKS5 +- tun2proxy (`/workspace/tun2proxy`): `src/udpgw.rs` and its SOCKS5 files (`socks.rs`, `proxy_handler.rs`): UDP-over-stream framing and flow-table prior art (analyzed in alktunnels phase-0-findings; the SOCKS5-specific parts feed OQ-SK-03). Also the endgame composition partner: it takes `--proxy socks5://...` upstream natively - (`src/args.rs`) — the vpn-like endgame is tunnel + local bind + + (`src/args.rs`); the vpn-like endgame is tunnel + local bind + tun2proxy (§Vision, principle 6). -- alkhttp — `/workspace/@alkdev/alkhttp`: the native-only precedent in - the suite (axum/reqwest/hyper, no wasm target) — cited in OQ-SK-04 +- alkhttp (`/workspace/@alkdev/alkhttp`): the native-only precedent in + the suite (axum/reqwest/hyper, no wasm target), cited in OQ-SK-04 as the "wasm-clean is preferred, not mandatory" escape hatch. -- iroh — `/workspace/iroh` `iroh/Cargo.toml` (the noq dependency - posture: `noq = "1.2.0"` from the n0 workspace) — context for +- iroh: `/workspace/iroh` `iroh/Cargo.toml` (the noq dependency + posture: `noq = "1.2.0"` from the n0 workspace), context for OQ-SK-06's dependency question (transferred to the alknet rewrite); also the privacy motivation that originally motivated the client wrapper (relays/peers see the real IP). ## Convergence checklist (what Phase 0 must produce) -- [x] Vision + guiding principles captured (this doc, §Vision — +- [x] Vision + guiding principles captured (this doc, §Vision, including the component split and the scope resolution) - [x] Prior-art pass complete: fast-socks5 surface verified (§Prior art), alktunnels/alknet/noq lineage mapped, anti-prior-art list written; iroh-socks5 evaluated (structural sibling, not the base; - OQ-SK-03 prior art — `docs/research/iroh-socks5-eval.md`); + OQ-SK-03 prior art in `docs/research/iroh-socks5-eval.md`); ownership premise corrected 2026-09-20 (fast-socks5 is third-party upstream; the posture is the inline vendor, OQ-SK-04 resolved) -- [x] OQ-SK-01 (dial policy) — shape resolved empirically (POC #1: +- [x] OQ-SK-01 (dial policy): shape resolved empirically (POC #1: dial callback at the command-read interception point, boxed halves, no trait); the A/B assembly-layer choice is a Phase 1 policy decision -- [x] OQ-SK-02 (auth mapping) — settled 2026-09-13 ("both," split by +- [x] OQ-SK-02 (auth mapping) settled 2026-09-13 ("both," split by the door; POC #1 validated the seam end to end); Phase 1 formalizes the door's credential→identity mapping -- [x] OQ-SK-03 (UDP ASSOCIATE) — the handle-swap baseline settled +- [x] OQ-SK-03 (UDP ASSOCIATE): the handle-swap baseline settled 2026-09-13 and POC-validated end to end (POC #2, 2026-09-14); - the datagram framing is wire-stable once published — the Phase 1 - ADR (framing + demux-vs-phase) remains, with empirical ground -- [x] OQ-SK-04 (fast-socks5 wasm) — resolved 2026-09-20: the inline - vendor (the ownership premise was corrected — we don't own + the datagram framing is wire-stable once published, and the + Phase 1 ADR (framing + demux-vs-phase) remains, with empirical + ground +- [x] OQ-SK-04 (fast-socks5 wasm): resolved 2026-09-20, the inline + vendor (the ownership premise was corrected, since we don't own fast-socks5; BIND + the seam fixes must be carried regardless, so the vendor pays for itself independent of the adapter story; POC #4 de-risked the mechanism) -- [x] OQ-SK-05 (target policy) — dialer-refuses shape evidence-backed; +- [x] OQ-SK-05 (target policy): dialer-refuses shape evidence-backed; per-target-ACL-for-free validated for the composed egress path (POC #2); scope-gate convention (`socks5:open`/`socks5:bind`) POC-validated in shape; the allowlist variant, exact scope convention, and the 2026-09-14 virtual-subnet proposal are Phase 1 decisions -- [x] OQ-SK-06 (noq client) — resolved by the scope decision +- [x] OQ-SK-06 (noq client) resolved by the scope decision (2026-09-14): transferred to the alknet rewrite; this crate's channels-native client story is POC-complete without it -- [x] OQ-SK-07 (POC register) — closed: #1, #2, #4, #5 passed; #3 +- [x] OQ-SK-07 (POC register) is closed: #1, #2, #4, #5 passed; #3 dropped with the scope decision; findings in `docs/research/` -- [x] OQ-SK-08 (BIND) — use case + mechanism found; POC #5 validated +- [x] OQ-SK-08 (BIND): use case + mechanism found; POC #5 validated the shape end to end; the ask ledger landed 2026-09-18/20 (superseded in shape by the bind-first/forwarded graduation); the remaining Phase 1 ADR (ship in base crate? default accept variant?) has empirical ground under it -- [x] OQ-SK-09 (channel capacity) — proxy-workload analysis written +- [x] OQ-SK-09 (channel capacity): proxy-workload analysis written (wire change rejected; config is the mechanism; in-channel port multiplexing considered and rejected); Phase 1 pins the documented proxy-workload recommendation -- [x] Targeted POCs run + summaries in `docs/research/` — complete; +- [x] Targeted POCs run + summaries in `docs/research/`: complete; no pending POCs remain for this crate's Phase 0 - [x] Converge: recommended approach written up, ready to hand to the Architect for Phase 1 (§Convergence, 2026-09-14) -## Convergence — the recommended approach +## Convergence: the recommended approach This is Phase 0's output: a clear WHAT/WHY with validated approaches, ready for the Architect. @@ -1073,13 +1075,13 @@ except the two explicit, optional, feature-gated assembly shapes ### The recommended approach (validated pieces → crate shape) -- **Protocol base: the vendored fast-socks5 explicit typestate API** — +- **Protocol base: the vendored fast-socks5 explicit typestate API**: the `T: AsyncRead + AsyncWrite + Unpin` genericity carries both substrates (POC #1: a channels `BiStream` drops in directly; the local backend feeds a `TcpStream`). Drive the typestate directly for - UDP ASSOCIATE (`run_udp_proxy_custom` is unusable on channels — - unconditional kernel bind; the vendor fixes the seam in-tree). -- **Wire shape: the handle swap** (POC #2-validated) — one ALPN + UDP ASSOCIATE (`run_udp_proxy_custom` is unusable on channels due to + its unconditional kernel bind; the vendor fixes the seam in-tree). +- **Wire shape: the handle swap** (POC #2-validated): one ALPN (`alk/socks5`), one open op (`channels/socks5/sub`), trivial params; the RFC conversation is the data plane; CONNECT = `pump_bidi` pass-through post-reply (POC #1); ASSOCIATE = the `select!`-driven @@ -1087,24 +1089,24 @@ except the two explicit, optional, feature-gated assembly shapes length codec; RSV/FRAG only at front doors; channel EOF ends the association; per-datagram addressing, no flow table); BIND = the phase model extended (POC #5). -- **Egress/accept seams: the dial-callback shape, extended** — `DialFn` +- **Egress/accept seams: the dial-callback shape, extended**: `DialFn` for CONNECT (POC #1's command-read interception point), `EgressFactory` for ASSOCIATE (POC #2), and `BindAcceptFactory` for BIND (POC #5): all function-not-trait, all returning boxed halves. Local-socket and composed providers are assembly-layer policies; the composed variant's per-destination open cost is measured (~0.8 ms) and its per-target-ACL-for-free benefit is validated. The composed - providers compose against alktunnels 0.2.0 — the direct op + providers compose against alktunnels 0.2.0: the direct op (`channels/tunnel/direct`, ADR-007) is the dynamic-target channel for CONNECT, and `open_direct` + `Substrate::Udp` is the composed ASSOCIATE vehicle (both substrates, one op, one grant: `tunnel:direct`); the hub-leg for relaying them is alkcall 0.8.0's in-tree `ChannelRelay`/`HubLegTemplate` (ADR-051). -- **Auth: "both," split by the door** (OQ-SK-02's settled posture) — +- **Auth: "both," split by the door** (OQ-SK-02's settled posture): producer side: the alkcall identity seam only (the in-band conversation runs NoAuth); the consumer's front door owns RFC 1929 for vanilla clients and maps credentials to the channel identity. -- **Layering: the alktty/alktunnels precedents** — substrate-agnostic +- **Layering: the alktty/alktunnels precedents**: substrate-agnostic protocol modules at the crate core; feature-gated backend modules (local binds) never imported by the protocol layer; producer + consumer + params modules mirroring POC #1/#2's structure. **The @@ -1113,19 +1115,20 @@ except the two explicit, optional, feature-gated assembly shapes §Amendment)**: extract the `T`-generic subset into this crate (the codecs, typestate machinery, client `use_stream` path; the kernel-net handlers and `socks4` are not copied, so there is no - `net` feature — POC #4's gate map survives as the extraction - recipe) — and carry the two extracted-region needs in the same + `net` feature; POC #4's gate map survives as the extraction + recipe) and carry the two extracted-region needs in the same vendor: BIND (absent upstream; POC #5's typestate-driven shape) and the `new_reply` publication. `/workspace/fast-socks5` stays the differential-testing reference; note the vendor point (v1.0.0) in the vendored code. - **Open for Phase 1 ADRs** (inputs collected, decisions pending): the demux-vs-phase extensibility trade (evidence now favors the phase - model — no in-band control vocabulary was needed across both POCs); - the datagram-framing ADR (the one-way door — decide with the first - consumer in sight); the params-format/ALPN naming ADRs (wire-stable + model, since no in-band control vocabulary was needed across both + POCs); the datagram-framing ADR (the one-way door; decide with the + first consumer in sight); the params-format/ALPN naming ADRs (wire-stable once published, no consumers exist yet); `SOCKS5_OPEN_SCOPE` - convention (must distinguish BIND from CONNECT/ASSOCIATE — OQ-SK-08's + convention (must distinguish BIND from CONNECT/ASSOCIATE, per + OQ-SK-08's security note); the door's credential→identity mapping shape (OQ-SK-02); the A/B egress provider default (OQ-SK-01); BIND's base-crate inclusion + default accept variant (OQ-SK-08); the @@ -1136,21 +1139,21 @@ except the two explicit, optional, feature-gated assembly shapes ### What Phase 0 could NOT answer (handed to Phase 1) -- The datagram-framing ADR (the one-way door) — empirical ground in - hand: framing validated, LRU/dedup semantics pinned, open cost +- The datagram-framing ADR (the one-way door): empirical ground in + hand, i.e. framing validated, LRU/dedup semantics pinned, open cost measured. Decide with the first consumer in sight. -- Target-policy shape (OQ-SK-05) — the dialer-refuses shape is +- Target-policy shape (OQ-SK-05): the dialer-refuses shape is evidence-backed; the declarative-allowlist variant stays a Phase 1 - choice. (The 2026-09-14 virtual-subnet proposal is recorded there — + choice. (The 2026-09-14 virtual-subnet proposal is recorded there: dial-callback translation, no wire change.) -- Channel capacity for proxy workloads (OQ-SK-09) — wire change +- Channel capacity for proxy workloads (OQ-SK-09): wire change rejected; assembly-layer config + a documented proxy-workload cap recommendation stay Phase 1 decisions. -- BIND — validated end to end (POC #5); the graduation supplied the +- BIND: validated end to end (POC #5); the graduation supplied the composed variant's full-fidelity substrate (alktunnels 0.2.0's bind-first establisher + forwarded op). Deciding whether BIND ships in the base crate (and which accept variant is default) is a Phase 1 - ADR — support is additive (old consumers still get the RFC refusal), + ADR; support is additive (old consumers still get the RFC refusal), so nothing here is a one-way door. - The door's credential→identity mapping shape (OQ-SK-02) and the - scope-string convention — Phase 1 ADRs with POC ground under them. \ No newline at end of file + scope-string convention: Phase 1 ADRs with POC ground under them. \ No newline at end of file diff --git a/docs/research/poc-bind-findings.md b/docs/research/poc-bind-findings.md index 4b1de64..9af8bf7 100644 --- a/docs/research/poc-bind-findings.md +++ b/docs/research/poc-bind-findings.md @@ -1,14 +1,14 @@ --- 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) --- -# 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: `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), fast-socks5 1.0.0 (crates.io). 9 tests, all pass; `cargo clippy --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 callback; the `socks5:bind` scope gate.** fast-socks5 parses 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 `read_command()` returns `(cmd, target)` and the wrapper branches; `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 -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`: the typestate runs `accept_no_auth → read_command` (BIND arrives as `Socks5Command::TCPBind`), reply#1 carries `BND.ADDR`, the quiet wait selects the one inbound accept against the control stream, reply#2 carries the peer address, and `pump_bidi` - (inline — R-02) two-pumps the accepted connection against the - channel. Zero per-packet headers in-band (the CONNECT pass-through - conclusion extends literally — BIND's post-reply#2 stage is a + (inline, R-02) two-pumps the accepted connection against the + channel. Zero per-packet headers in-band; the CONNECT pass-through + conclusion extends literally (BIND's post-reply#2 stage is a 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 carries the listener's *actual* `local_addr` (a real network-B 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 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 listen-tunnel resource (`register_tunnel_listen_openable` + `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 → the app server's connection → back through the pump. Per-listener ACL for free (the tunnel resource's ACL governs who can mint the - listener), hub-relayable — the variant's whole point. -4. **The scope gate — confirmed.** The pump handler reads the + listener), hub-relayable, the variant's whole point. +4. **The scope gate is confirmed.** The pump handler reads the open-op `AuthContext`'s identity and requires `socks5:bind` before 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 from proxy egress; one identity having egress does not imply 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. -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 BIND shares; RFC 1928 §3's "BIND: no further control data" reading); the session is refused with no data plane. A client that BINDs and vanishes (EOF mid-wait) tears the listener down - with its session — the accept future (which owns the listener) - is dropped by the `select!` branch; a fresh dial to the - advertised address fails with connection refused. The session's - natural end (half-close post-data-plane) releases the listener - the same way. -6. **Failure mapping — confirmed RFC-faithful.** A factory failure + with its session; the accept future (which owns the listener) + is dropped by the `select!` branch; a fresh dial to the + advertised address fails with connection refused. The session's + natural end (half-close post-data-plane) releases the listener + the same way. +6. **Failure mapping is confirmed RFC-faithful.** A factory failure (pre-reply#1: an unbindable port, a held port) maps to reply#1 with the factory error's reply code (shortage → GeneralFailure); an accept-stage failure (post-reply#1) maps to reply#2 with the mapped refusal code and no data plane. No phantom listeners, no 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 POC #2 skeleton) still serve on the same `channels/socks5/sub` op after the BIND branch lands. The RFC 1928 command surface is now 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 a full vanilla BIND conversation: an in-process RFC 1928 client (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) -**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.** SOCKS5 BIND is address-first (reply#1 must carry `BND.ADDR` *before* the app server can dial), while alktunnels' listen establisher is 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 deadlocked into the establishment timeout: the app server cannot dial 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 -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 the bound address without consuming an accept* (ledger ask #1): the stand-in is the factory's carried `bind_addr` (the expected `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 half), and (2) accepted handles carrying peer metadata (the reply#2 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 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 mapped open error). Correct-by-backstop, but a real BIND deployment (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 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). 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 `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 inside the table entry; the *compiler* should enforce it"). The fix: -`BindTargetHandle` carries `_session: Option` — the +`BindTargetHandle` carries `_session: Option`; the 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 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 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 (`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 @@ -154,7 +154,7 @@ item #2 is the fidelity ask. **The quiet wait is `select!`-single-shot, not a loop** (unlike ASSOCIATE's stage): the accept resolves at most once, and the control 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 has exactly two exits (the accept, or the control stream's 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 listener (local variant) or the tunnel-open + popped halves (composed 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 end-to-end (the same ownership lesson as POC #2's egress, mirrored). ## 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 `read_address` codec (reply#1's codec applied again; no second codec hand-rolled). The consumer's reply#2 reader (`read_reply2`) is the Phase 1 client-session sketch. - `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 expected far-side address). No upstream change needed. - fast-socks5's `new_reply` helper is private; the BIND reply#2 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 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 - `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). - 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. - 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* distinction. `socks5:bind` as a scope string follows the `socks5:open` convention (Phase 1 pins the exact convention). ## 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 -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 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) binds during establishment, so the open-op reply natively carries 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; this ask's stand-in (the factory's carried `bind_addr`) is retired, 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 accept-first surface (`listen_establisher` + `AcceptQueue`) remains 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`, 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 is a producer-minted u32 token (Amendment 1, 2026-09-18), not the 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 form. Also still open from POC #4: the `net` feature gate and the `run_udp_proxy_custom` unconditional-bind seam. (Posture note, 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 - resolved. 2026-09-21: under the extraction — ADR-011 §Amendment — + longer asks; all three move into the inline vendor, OQ-SK-04 + resolved. 2026-09-21: under the extraction (ADR-011 §Amendment), `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 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 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: 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 "reply#2 carries the address of the incoming connection" is unfilled on the composed path. Shape: `TargetHandle` gaining an - optional `peer: Option` (or a sibling - `AcceptedHandle`) — additive, no wire change. -3. **(fast-socks5, courtesy)** `new_reply` is private — publish it + optional `peer: Option` (or a sibling + `AcceptedHandle`); additive, no wire change. +3. **(fast-socks5, courtesy)** `new_reply` is private; publish it (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 `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 kernel-net region, which is not copied.) @@ -275,7 +275,7 @@ reply#2) are honest interim shapes. ## 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); both accept variants implemented (local faithful + composed 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 future) with the two upstream asks pinned as the remaining blockers for variant 2's full fidelity. Remaining for Phase 1: ship-in-base- - crate + default-accept-variant ADR (the support is additive — old - consumers still get the RFC refusal — so nothing here is a + crate + default-accept-variant ADR (the support is additive, old + consumers still get the RFC refusal, so nothing here is a one-way door); the establishment-bound posture; the exact scope-string convention. - **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-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 for the accept plane too. ## POC placement 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 from this repo. Findings land here; the crate is the artifact. \ No newline at end of file diff --git a/docs/research/poc-connect-wasm-findings.md b/docs/research/poc-connect-wasm-findings.md index b5335b4..381316f 100644 --- a/docs/research/poc-connect-wasm-findings.md +++ b/docs/research/poc-connect-wasm-findings.md @@ -3,7 +3,7 @@ status: complete 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. @@ -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 (`local_front_door`, the curl front door). All pass. - **POC #4** (fast-socks5 wasm fork-minimality): `fs5-wasm-poc` - (scratch crate, kept at `/workspace/alksocks-fs5-wasm-poc/` — not - part of this repo; the finding is the artifact). fast-socks5's + (scratch crate, kept at `/workspace/alksocks-fs5-wasm-poc/` (not + part of this repo; the finding is the artifact)). fast-socks5's `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 -1. **`BiStream`-as-`T` genericity — confirmed.** The channels +1. **`BiStream`-as-`T` genericity confirmed.** The channels `BiStream` (`alkcall::core::types`, `Box`, impls `AsyncRead + AsyncWrite`, is `Unpin` + `Send`) satisfies fast-socks5's `Socks5ServerProtocol` server typestate and `Socks5Stream::use_stream` client path directly. No adapter, 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 `socks5:open`) with an establisher + pump handler exactly as 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, target_write)` inline; echo round-trips clean through 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; `ChannelOpenError::CallFailed` carries it; no phantom channel. The establisher's per-call identity arrives as expected (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) - in the CONNECT reply — the faithful per-RFC error mapping - AGENTS.md convention 2 requires — with no phantom data plane. + in the CONNECT reply, the faithful per-RFC error mapping + AGENTS.md convention 2 requires, with no phantom data plane. 6. **The front door works with a vanilla client (stretch goal).** The consumer-side local exposure shape: bind a real TCP listener, 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) -**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 method negotiation and the CONNECT command (with its target address) arrive in-stream, after channel establishment. The layering the POC 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 never sees a target. - **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 harness had to do:** the accept-side install hook must call `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; modeled mTLS posture. A real mTLS transport would resolve the peer 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. ### 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()` + `addr.port()` separately (the POC hit this; the wrapper's target 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 (domain form passes through unresolved, per OQ-SK-05's DNS question). - `SocksServerError::AuthenticationRejected` is a unit variant (no message field) in 1.0.0. - `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. - The open-op params are trivially empty (`{}`); the spec's input schema is `{type: object}`. Params stay empty unless a later ADR adds per-session options. -## POC #4 — fast-socks5 wasm fork-minimality +## POC #4: fast-socks5 wasm fork-minimality ### Baseline @@ -117,7 +117,7 @@ but the wiring requirement is implicit. OQ-SK-04's premise. (alksocks-connect-poc itself re-confirms: the dep 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 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` | 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). ### Results - `cargo check --no-default-features` (native): clean. - `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 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. - `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`). ### 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 implements the whole RFC conversation. tokio itself needs `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 `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. Remaining for Case 1: the fork posture is vendoring *into this crate* (per phase-0: vendor the `T`-generic subset, gate the rest), keeping `/workspace/fast-socks5` as the differential reference; the upstream 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 OQ-SK-04.)** ### 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; 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 @@ -182,7 +182,7 @@ into the vendor with it. 1. **Feature-gate kernel-net behind a default-on `net` feature** (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 recipe, not in-tree cfg.) 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 channels path must drive the typestate directly. Make the `udp_bind_random_port` step optional / accept a caller-supplied - socket. (Both #2 and the POC #5 courtesy item — publish `new_reply` - — ride the vendor; see `poc-bind-findings.md` §ledger statuses.) + socket. (Both #2 and the POC #5 courtesy item, publish `new_reply`, + ride the vendor; see `poc-bind-findings.md` §ledger statuses.) ## 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; boxed-halves return type; no trait (alktunnels' no-trait precedent holds). Where the callback *comes from* (alktunnels composition vs 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 propagation is a wiring requirement the producer harness performs. In-band SOCKS auth was not exercised (NoAuth only); the RFC-facing username/password path for the local backend remains Phase 1. - **OQ-SK-04 (wasm)**: fork-minimality verified; the decision reduced 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 phase-0 OQ-SK-04.)** - **OQ-SK-07**: #1 and #4 complete; #2 (UDP ASSOCIATE) and #3 (noq) diff --git a/docs/research/poc-udp-associate-findings.md b/docs/research/poc-udp-associate-findings.md index d5a1daf..08ae8f7 100644 --- a/docs/research/poc-udp-associate-findings.md +++ b/docs/research/poc-udp-associate-findings.md @@ -3,7 +3,7 @@ status: complete 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: `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 -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 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 `udp_bind_random_port` makes it unusable on channels, per the corrected §Prior art caveat; driving the typestate directly is the workaround the upstream ask would retire). -2. **The in-band frame codec — confirmed.** The wire is alktunnels' - boundary-preserving length codec (`frame_datagram`/`DatagramReader` - — empty payload legal, never EOF; frames split/batch across channel +2. **The in-band frame codec is confirmed.** The wire is alktunnels' + boundary-preserving length codec (`frame_datagram`/`DatagramReader`; + empty payload legal, never EOF; frames split/batch across channel 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 in-band; it is refused at front doors (FRAG ≠ 0 → refuse, the iroh-socks5 v1 posture). The RFC-header codecs (`new_udp_header`/`parse_udp_request`) are used only at the 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 local-egress variant relays through one real `UdpSocket` with per-datagram `send_to`/`recv_from` (iroh-socks5's cut). Five in-order datagrams round-trip over 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 implicit-EOF sentinel (REQ-CH-01) reaches the producer's stage read - as EOF; the stage exits and the connection stays healthy (a fresh - associate succeeds immediately after). No extra control vocabulary - needed — the channels-level EOF is the association's end-of-life - signal, as the iroh-socks5 eval predicted. -5. **Control silence is structurally enforced — confirmed.** The + as EOF; the stage exits and the connection stays healthy (a fresh + associate succeeds immediately after). No extra control vocabulary + needed; the channels-level EOF is the association's end-of-life + signal, as the iroh-socks5 eval predicted. +5. **Control silence is structurally enforced, confirmed.** The frame's first byte is ATYP, so any post-reply control byte parses as a garbage ATYP and fails the association loud (`udp_undecodable_frame_ends_association`, with the unassigned ATYP 0x7F). The phase model needs no explicit mode byte: the reply 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 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"). - **Composed egress** (`ComposedEgress`): a lazy 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 + the dedup assertion (`open_count == 1` for repeated same-resource 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 UDP socket; the door's pump decodes RFC-header'd datagrams (`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 pipe shape); this test pins the UDP-specific claim: RFC codec at 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 CONNECT path is the POC #1 data plane unchanged (`connect_still_served_on_the_same_registration`). 10. **The identity-seam wiring requirement re-confirmed** for the composed path: the tunnel producer's channel-0 accept hook must 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 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 future's readiness contract is load-bearing.** The stage runs channel 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: 1. **The poll-then-block window loses replies.** The first iteration 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 is also the honest full-duplex shape (an egress may produce unsolicited inbound datagrams at any time). 2. **An instantly-ready `Ok(None)` starves the other branch with `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. The egress contract is therefore: *`next_reply` must be `Pending` 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, spins forever); `readable()` is the datagram-arrival readiness. 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.** `Egress::next_reply` returns the *inner* bytes (`addr + data`); 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 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 consumer side mirrors it: `UdpAssociation::send_datagram` frames once, 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 (the first cut dropped the reader state per call; the echo test's 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 ADR-005). One structural bug here: the first cut bound the opened 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 entry. The crate's Phase 1 equivalent: the egress owns its tunnels 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 - `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); `fast_socks5::util::target_addr::read_address` is the async in-stream equivalent. `frame`/`inner`/`unframe` in the POC's `datagram.rs` are the Phase 1 codec's direct sketch. - `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, do not hand-roll). - 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 door has a real address to report. - `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; 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). - The composed egress's per-destination attribution: the table'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. - `parse_udp_request` is async (though it completes immediately over a 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-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 = 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 question (the one-way-door extensibility argument) stays an ADR 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 needed, and post-reply garbage is refused structurally. - **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 - 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. - **OQ-SK-05 (target policy)**: the composed egress's per-target-ACL- 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 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 from this repo. Findings land here; the crate is the artifact. \ No newline at end of file diff --git a/docs/sdd_process.md b/docs/sdd_process.md index fef1e72..188c405 100644 --- a/docs/sdd_process.md +++ b/docs/sdd_process.md @@ -18,7 +18,7 @@ leverages: 4. **Task-Driven**: Structured task graphs with dependency analysis 5. **Safe Exit**: Always have a way to unblock progress when stuck 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 cost-benefit framework in taskgraph's framework docs. @@ -47,11 +47,11 @@ approaches **Process**: 1. Architect creates the architecture documentation structure: - - `docs/architecture/README.md` — index with doc table, ADR table, lifecycle - - `docs/architecture/overview.md` — package purpose, exports, dependencies - - `docs/architecture/.md` — one focused doc per component/area - - `docs/architecture/decisions/` — numbered ADR files (ADR-001, ADR-002, ...) - - `docs/architecture/open-questions.md` — centralized tracker with OQ-IDs + - `docs/architecture/README.md` (index with doc table, ADR table, lifecycle) + - `docs/architecture/overview.md` (package purpose, exports, dependencies) + - `docs/architecture/.md` (one focused doc per component/area) + - `docs/architecture/decisions/` (numbered ADR files: ADR-001, ADR-002, ...) + - `docs/architecture/open-questions.md` (centralized tracker with OQ-IDs) 2. All docs start in `Draft` status. Spec docs reference ADRs by number (not inline rationale) and OQs by number (not inline questions). 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 - Centralize open questions in `open-questions.md` with OQ-IDs - 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 - Iterate based on review feedback **Deliverables**: -- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle -- `docs/architecture/.md` — focused spec documents -- `docs/architecture/decisions/` — numbered ADR files -- `docs/architecture/open-questions.md` — centralized OQ tracker +- `docs/architecture/README.md`: index with doc table, ADR table, lifecycle +- `docs/architecture/.md`: focused spec documents +- `docs/architecture/decisions/`: numbered ADR files +- `docs/architecture/open-questions.md`: centralized OQ tracker --- @@ -179,17 +179,17 @@ sessions. **Mode**: Primary (manages worktrees and agent sessions) **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 limited set (current, notify, status). No mode toggle required. **Tools**: -- `worktree({action, args})` — spawn, sessions, dashboard, message, abort, +- `worktree({action, args})`, which dispatches spawn, sessions, dashboard, message, abort, cleanup - Bash (opencode CLI for session interaction) - 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) **Key Behaviors**: @@ -218,10 +218,10 @@ limited set (current, notify, status). No mode toggle required. **Tools**: - Read, Write, Edit, Glob, Grep, Bash -- `worktree({action: "notify", ...})` — report progress/blockers to coordinator -- `worktree({action: "current"})` — verify worktree assignment +- `worktree({action: "notify", ...})` to report progress/blockers to coordinator +- `worktree({action: "current"})` to verify worktree assignment - webSearch (documentation lookup) -- `memory` / `memory_compact` — context management (via @alkdev/open-memory, +- `memory` / `memory_compact` for context management (via @alkdev/open-memory, when available) **Key Behaviors**: @@ -388,7 +388,7 @@ Implement OAuth2 authentication with provider abstraction. ### Categorical Estimates 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`, `impact`, or `level` is a red flag indicating incomplete decomposition. See the 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 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 (`planning, decomposition, implementation, review, research`) already covers architecture activities: | 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. | -| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit — breaking a large architecture area into decideable units. | +| 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). | | Architecture review pass | `review` | Direct fit. | | Research / POC before a decision | `research` | Direct fit. | | 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 external-trigger tracker task represents the unblocking condition. It is 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 `Blocked on:` text. This is the surface for the task graph: `taskgraph` 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 `deferred(scope)` to `open` (or directly to `resolved` if no architecture 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. ## Review Injection @@ -509,7 +509,7 @@ Use graph analysis to determine where reviews should happen: ### Current (open-coordinator plugin) 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. ``` @@ -563,13 +563,13 @@ a worktree. When the open-memory plugin is available alongside open-coordinator, the coordinator gains: -- `memory({tool: "children", args: {sessionId: "..."}})` — view sub-agent +- `memory({tool: "children", args: {sessionId: "..."}})`: view sub-agent 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 -- `memory({tool: "context"})` — check context window usage before long +- `memory({tool: "context"})` to check context window usage before long 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 `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 -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. ## Document Structure @@ -660,7 +660,7 @@ Accepted | Proposed | Deprecated | Superseded (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. ### 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 - **Priority**: high | medium | low - **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 ``` diff --git a/fuzz/README.md b/fuzz/README.md index dece507..caf7d7a 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -5,19 +5,19 @@ libFuzzer targets for alksocks' wire surfaces (see ## Layout -- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers. -- `shared/` — the invariant logic, as a plain library so normal +- `fuzz_targets/`: one binary per target; thin `fuzz_target!` wrappers. +- `shared/`: the invariant logic, as a plain library so normal `cargo test` (stable toolchain) can replay the committed corpora through the same invariants (`fuzz/shared/src/*.rs` `#[cfg(test)]` modules; quinn's CI pattern). The fuzz binaries are nightly-only; the shared crate is stable-clean. -- `corpus//` — committed hand-made seeds (regenerate with +- `corpus//`: committed hand-made seeds (regenerate with `python3 fuzz/gen_fuzz_seeds.py` from the repo root). Grown corpora 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 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 @@ -31,7 +31,7 @@ libFuzzer targets for alksocks' wire surfaces (see `cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/` (or deeper) as the working directory so rustup selects the pinned -nightly toolchain — the detached runner handles that itself. +nightly toolchain; the detached runner handles that itself. ```sh # build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/) diff --git a/fuzz/gen_fuzz_seeds.py b/fuzz/gen_fuzz_seeds.py index cd0d61b..3087e6f 100644 --- a/fuzz/gen_fuzz_seeds.py +++ b/fuzz/gen_fuzz_seeds.py @@ -81,7 +81,7 @@ def datagram_codec_seeds(): # The joint-bound max-domain shape: a legal RFC payload that would # 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 # the decode walk; the encode-side 65766 rejection is pinned in # the shared invariants. diff --git a/fuzz/shared/src/arbitrary_value.rs b/fuzz/shared/src/arbitrary_value.rs index 74632a4..846c1f4 100644 --- a/fuzz/shared/src/arbitrary_value.rs +++ b/fuzz/shared/src/arbitrary_value.rs @@ -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 //! bounds keep exec/s high (serde_json's own 128-depth recursion limit //! is the decode-side guard; this generator explores structure). diff --git a/fuzz/shared/src/datagram_codec.rs b/fuzz/shared/src/datagram_codec.rs index 23236a4..5673fa3 100644 --- a/fuzz/shared/src/datagram_codec.rs +++ b/fuzz/shared/src/datagram_codec.rs @@ -35,7 +35,7 @@ fn probe_addr() -> TargetAddr { pub fn fuzz_datagram_codec(data: &[u8]) { // The infallible reassembly direction: any chunk sequence feeds // 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 whole_dgs = whole.feed(data); 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 // 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 // addr to re-frame). The frame count holds over every frame; the // byte-for-byte re-frame holds over the decodable ones. @@ -136,7 +136,7 @@ pub fn fuzz_datagram_codec(data: &[u8]) { } Err(WireError::EmptyFrame) => { // 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; assert_eq!(declared, 0); 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 // garbage shapes: the declared length carried a // 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 // 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 - // 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). 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"); @@ -214,7 +214,7 @@ pub fn fuzz_datagram_codec(data: &[u8]) { // Mid-frame observability: after the final feed, the reader is // mid-frame exactly when the input ends inside a frame (a partial // 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); assert_eq!( whole.is_mid_frame(), @@ -236,7 +236,7 @@ fn result_shapes(dgs: &[Result<(TargetAddr, bytes::Bytes), WireError>]) -> Vec 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 /// 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. fn split_frames(data: &[u8]) -> (Vec<&[u8]>, usize) { 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 -/// 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 /// `(addr, payload)` inner structure: a frame's empty payload needs /// `declared == addr_len`, not `declared == 0`). Domain-form frames /// 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. fn count_empty_payload_frames(data: &[u8]) -> usize { 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 // incomplete* frame: a remainder ≥ 2 bytes means the length header // 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; remainder >= 2 } diff --git a/fuzz/shared/src/params_parse.rs b/fuzz/shared/src/params_parse.rs index 7c2d024..69f3093 100644 --- a/fuzz/shared/src/params_parse.rs +++ b/fuzz/shared/src/params_parse.rs @@ -31,12 +31,12 @@ pub fn fuzz_params_parse(data: &[u8]) { } 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 - // (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 // 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 // from ever reaching the type; the serde pin is the second gate. let parses = serde_json::from_value::(value.clone()).is_ok(); @@ -54,7 +54,7 @@ fn parse_shapes(value: &Value) { assert_ne!( value, &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 // 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). let code = value .get("code") diff --git a/fuzz/shared/src/rfc1928_codec.rs b/fuzz/shared/src/rfc1928_codec.rs index 74b4b4b..d4efdfa 100644 --- a/fuzz/shared/src/rfc1928_codec.rs +++ b/fuzz/shared/src/rfc1928_codec.rs @@ -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 //! 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 //! reply-code table. Both protocol directions are covered: the server //! typestate (the producer drives it against consumer bytes) and the @@ -80,7 +80,7 @@ async fn fuzz_read_address(data: &[u8]) { } } 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). if let AddrError::IncorrectAddressType = err { assert!( @@ -191,7 +191,7 @@ async fn fuzz_udp_header(data: &[u8]) { /// `ReplyError::from_u8` over the full u8 domain: total, no panic, /// 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]) { for &code in data { let reply = ReplyError::from_u8(code); diff --git a/src/composed/mod.rs b/src/composed/mod.rs index 76ad5b2..d4c6f5f 100644 --- a/src/composed/mod.rs +++ b/src/composed/mod.rs @@ -1,36 +1,37 @@ //! 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 //! alktunnels 0.2.0 direct-op channel -//! ([`alktunnels::TunnelSession::open_direct`], `Substrate::Udp`) — -//! and [`bindfirst_accept`] — the TCP BIND accept whose listener lives +//! ([`alktunnels::TunnelSession::open_direct`], `Substrate::Udp`); +//! and [`bindfirst_accept`], the TCP BIND accept whose listener lives //! on a bind-first listen resource (ADR-007 variant 2 / ADR-008's //! composed accept). //! //! **Per-target ACL for free** (ADR-008's composed benefit): each -//! destination rides its own `channels/tunnel/direct`-governed channel -//! — the producer's `tunnel:direct` scope gate and dial policy apply -//! per destination, no SOCKS5-layer allowlist needed (ADR-006/ADR-012: -//! whatever ACL governs the tunnel capability governs everything -//! reachable through it). +//! destination rides its own `channels/tunnel/direct`-governed channel, +//! so the producer's `tunnel:direct` scope gate and dial policy apply +//! per destination, with no SOCKS5-layer allowlist needed +//! (ADR-006/ADR-012: whatever ACL governs the tunnel capability +//! governs everything reachable through it). //! //! The shape is POC #2's `ComposedEgress` //! (`/workspace/alksocks-udp-poc/src/egress.rs`), ported to the landed -//! seam types: a lazy destination→tunnel table — each new destination -//! opens one UDP-tunnel channel, a relay task per tunnel drains -//! inbound datagrams into a shared queue, the write half stays in the -//! table (LRU-capped; dropping a write half EOFs the tunnel -//! producer-side; the entry's session drop reaps the channel — -//! alktunnels ADR-005's teardown contract). +//! seam types: a lazy destination→tunnel table, where each new +//! destination opens one UDP-tunnel channel, a relay task per tunnel +//! drains inbound datagrams into a shared queue, the write half stays +//! in the table (LRU-capped; dropping a write half EOFs the tunnel +//! producer-side; the entry's session drop reaps the channel, which +//! is alktunnels ADR-005's teardown contract). //! //! Contracts (the landed seam types' normative docs, honored here): //! //! - **The readiness contract** (load-bearing, `Egress::next_reply`): -//! parks on the shared inbound queue (`recv().await` — `Pending` -//! until a relay task delivers); a closed queue (all relays ended) -//! returns `Err` — never a hot `Ok(None)`. The stage's zero-yield -//! defense (review 002 R-1) is the backstop, not the design. -//! - **Framing-once**: replies carry *inner* bytes (`addr + data` — +//! parks on the shared inbound queue (`recv().await`, hence +//! `Pending` until a relay task delivers); a closed queue (all +//! relays ended) returns `Err`, never a hot `Ok(None)`. The stage's +//! zero-yield defense (review 002 R-1) is the backstop, not the +//! design. +//! - **Framing-once**: replies carry *inner* bytes (`addr + data`; //! the reply address is the entry's recorded destination, the RFC's //! per-datagram source semantics); the length prefix is the wire //! layer's, applied exactly once downstream. `send` takes the @@ -39,34 +40,34 @@ //! wire's. //! - **Per-datagram `send` is awaited inline** (the stage //! 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 //! (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). //! - **The resource/target mapping**: `open_direct` takes a -//! [`SubstrateAddr`] — the destination's [`TargetAddr`] renders to -//! it (IP forms direct; domain form passes through unresolved — +//! [`SubstrateAddr`], which the destination's [`TargetAddr`] renders +//! to (IP forms direct; domain form passes through unresolved, //! producer-side resolution, the ssh `-D` semantic; ADR-012). //! //! **The Sync-bound wrinkle** (the batch-1 review's seam contract vs //! alktunnels' surface): the landed `TargetHandle`/`BindTargetHandle` //! halves are `Box`, 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 //! a `dyn AsyncReadWrite` trait object). The owner-wrapper posture //! (egress.rs's documented contract: "keep-alive state a substrate -//! needs rides inside the halves — a provider-side owner wrapper") -//! takes two shapes here: [`SessionOwner`] (the generic half-wraps- -//! session wrapper — the egress's design point, exported as the -//! wrapper-aware deployment's surface) and the accept's own +//! needs rides inside the halves, a provider-side owner wrapper") +//! takes two shapes here: [`SessionOwner`] (the generic +//! half-wraps-session wrapper, the egress's design point, exported as +//! the wrapper-aware deployment's surface) and the accept's own //! [`SyncHalves`]/[`OwnedWrite`] adapter pair (a `std::sync::Mutex` -//! around the half — `Mutex` is `Sync`, no unsafe — with +//! around the half; `Mutex` is `Sync`, no unsafe; with //! the channel session owned inside the payload, [`ForwardedOwner`]: //! dropping the handle drops the session AND the halves together). -//! 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); -//! the relay task owns the bare read half. +//! For the egress the wrapper is not the handle payload's burden, +//! because the egress owns the session in the table entry (the POC's +//! shape); use std::collections::HashMap; use std::net::SocketAddr; @@ -91,14 +92,14 @@ use crate::egress::{ pub use crate::rfc1928::util::target_addr::TargetAddr; 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). pub const DEFAULT_MAX_TUNNELS: usize = 256; /// The destination's render into the direct op's [`SubstrateAddr`] (the /// resource/target mapping): IP forms direct; domain form passes /// through unresolved (producer-side resolution, the ssh `-D` -/// semantic — ADR-012). +/// semantic, per ADR-012). pub fn substrate_addr_of(addr: &TargetAddr) -> SubstrateAddr { match addr { 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 /// 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). pub fn key_of(addr: &TargetAddr) -> String { 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 -/// 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 /// [`SyncHalves`]/[`OwnedWrite`] adapter pair instead): an alktunnels /// half wrapped with its [`TunnelSession`] inside. The landed handle /// payloads carry `+ Sync` (the /// `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 -/// (the session's drop reaps the channel entry — alktunnels ADR-005). -/// Whether the wrapper is `Sync` (the accept's need) is the half's -/// own `Sync`ness — asserted by the compile-shape test against the -/// alktunnels 0.2.0 surface; the egress needs only `Send` (the relay -/// task), which the wrapper always has. +/// (the session's drop reaps the channel entry, per alktunnels +/// ADR-005). Whether the wrapper is `Sync` (the accept's need) is the +/// half's own `Sync`ness, asserted by the compile-shape test against +/// the alktunnels 0.2.0 surface; the egress needs only `Send` (the +/// relay task), which the wrapper always has. pub struct SessionOwner { half: H, session: TunnelSession, @@ -187,17 +189,17 @@ pub struct ComposedEgress { order: std::collections::VecDeque<(String, TargetAddr)>, inbound: tokio::sync::mpsc::Receiver, inbound_tx: tokio::sync::mpsc::Sender, - /// 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 /// [`Inbound::Ended`]) and the queue is drained, `next_reply` /// returns `Err` (the association end), never a hot `Ok(None)`. /// 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, /// Whether any tunnel has ever been opened. The lazy table starts /// empty: a fresh association's `next_reply` parks until the first /// `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 /// ended-relay `Err` fires only once at least one tunnel existed /// 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 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). #[derive(Debug)] enum Inbound { @@ -214,11 +216,11 @@ enum Inbound { } /// 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 -/// 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 -/// the channel — alktunnels ADR-005). +/// the channel, per alktunnels ADR-005). struct TableEntry { _session: TunnelSession, write: Box, @@ -229,7 +231,7 @@ struct TableEntry { /// into the egress's shared inbound queue. Exits on EOF (the tunnel /// ended); the write half stays in the table until eviction or stage /// 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 /// destination). async fn relay_read( @@ -255,7 +257,7 @@ async fn relay_read( 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 /// here, the wire layer's `frame_inner` adds it downstream). The /// oversize guard mirrors the wire layer's frame bound. @@ -276,10 +278,10 @@ fn inner_bytes(addr: &TargetAddr, payload: &[u8]) -> Result impl ComposedEgress { /// Build the egress (the per-association ASSOCIATE seam object). /// `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` - /// ACL governs which destinations the channels accept — the - /// per-target-ACL-for-free benefit). + /// ACL governs which destinations the channels accept, which is + /// the per-target-ACL-for-free benefit). pub fn new(client: Arc, max_tunnels: usize) -> Self { let (tx, rx) = tokio::sync::mpsc::channel(64); 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). async fn entry_for(&mut self, addr: &TargetAddr) -> Result<&mut TableEntry, EgressError> { let key = key_of(addr); @@ -330,7 +332,7 @@ impl ComposedEgress { self.order.push_back((key.clone(), addr.clone())); // The entry was inserted above (or the key was already // 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). self.entries.get_mut(&key).ok_or_else(|| { EgressError::General(format!( @@ -366,7 +368,7 @@ impl Egress for ComposedEgress { // A non-blocking first pass over the shared queue (the // relay tasks may have queued replies already), then 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 { match self.inbound.try_recv() { Ok(Inbound::Datagram(key, dg)) => { @@ -376,8 +378,8 @@ impl Egress for ComposedEgress { }; return Ok(Some(inner_bytes(&addr, &dg)?)); } - // A relay ended (its tunnel's EOF drained) — - // count it; the closed-queue condition is + // A relay ended (its tunnel's EOF drained); + // count it, and the closed-queue condition is // every relay ended. Ok(Inbound::Ended) => { 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 // relay set must NOT spin the stage's select (an // instantly-ready None would starve the channel - // branch) — end the association instead. A fresh - // association (no tunnel ever opened) parks here — - // the channel branch governs until the first + // branch), so the stage ends the association + // instead. A fresh association (no tunnel ever + // opened) parks here, and the channel branch + // governs until the first // datagram arrives. return Err(EgressError::General("all tunnel relays ended".into())); } @@ -405,7 +408,7 @@ impl Egress for ComposedEgress { }; 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 // association ends (never a hot `Ok(None)`). Some(Inbound::Ended) => { @@ -425,8 +428,8 @@ impl Egress for ComposedEgress { /// producer.md §Wiring): the `EgressFactory` the producer /// registration consumes. One per ASSOCIATE; the stage owns the /// returned egress; stage exit drops it (write halves dropped = EOF -/// to the tunnel producers; the entries' sessions reap the channels — -/// alktunnels ADR-005's teardown contract). +/// to the tunnel producers; the entries' sessions reap the channels, +/// per alktunnels ADR-005's teardown contract). /// /// # Backpressure (inherited knowingly) /// @@ -450,15 +453,16 @@ pub fn direct_egress(client: Arc, max_tunnels: usize) -> EgressFa /// channel's `BiStream` + the forwarded op's `peer` param). One entry /// per live BIND session; the forwarded handler (the provider's shared /// opener half) removes and completes the entry on correlation. A -/// dropped receiver (client-gone teardown) makes completion fail — the -/// handler drops the channel stream (teardown-at-birth). +/// dropped receiver (client-gone teardown) makes completion fail, so +/// the handler drops the channel stream (teardown-at-birth). pub type CorrelationRegistry = Arc>>>; /// What a correlated forwarded session delivers: the forwarded -/// channel's `BiStream` (raw pass-through — the SOCKS5 BIND data plane -/// is raw on both ends) + the forwarded op's `peer` param (reply#2's -/// real peer — the graduation's win). Public because the +/// channel's `BiStream` (raw pass-through, since the SOCKS5 BIND data +/// plane is raw on both ends) + the forwarded +/// op's `peer` param (reply#2's real peer, the graduation's win). +/// Public because the /// [`CorrelationRegistry`] it travels through is public API surface /// (the assembly holds the handle [`register_forwarded_serving`] /// 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 /// 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 /// handler that returns early reaps the channel at birth). The pump /// bridges the forwarded channel's `BiStream` to a local duplex pair: /// 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`] -/// payloads (dropping the handle drops the far end → the pump sees EOF -/// → the task returns → the wrapper reaps the forwarded channel — -/// teardown-at-birth, structural). A correlation miss (no waiting -/// session — client gone, or a token nobody minted) drops the stream: +/// payloads (dropping the handle drops the far end → the pump sees +/// EOF → the task returns → the wrapper reaps the forwarded channel, +/// i.e. teardown-at-birth, structural). A correlation miss (no +/// waiting +/// session (client gone, or a token nobody minted) drops the stream: /// 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). fn make_forwarded_handler(registry: CorrelationRegistry) -> alktunnels::ForwardedHandler { 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: // 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 // producer pump's far.read EOF; far.read needs near.write // shutdown; near.write needs bidi.read EOF). The bridge // therefore completes on EITHER direction's EOF and drops // everything: the far halves (the SOCKS5 pump sees EOF), // the near halves, and the BiStream (the forwarded channel - // tears down; the wrapper's inline await returns — R-02 — - // and reaps the entry). + // tears down; the wrapper's inline await returns, per + // R-02, and reaps the entry). let (near_read, near_write) = tokio::io::split(near); 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 /// 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 /// everything: the teardown-at-birth discipline, compiler-enforced. -/// The owner is the [`TunnelListener`] (NOT an adopted session — the -/// forwarded channel's entry is the producer's, adopted inside the +/// The owner is the [`TunnelListener`] (NOT an adopted session, since +/// the forwarded channel's entry is the producer's, adopted inside 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 /// 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 /// channel at birth) is the reason the listener is forced into the /// future with a whole-struct rebinding. @@ -554,14 +560,14 @@ struct ForwardedOwner { /// The `BindTargetHandle` read-half adapter over the forwarded /// 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' /// `+ Sync` bound directly. A `std::sync::Mutex` guard is the /// no-unsafe Sync proof (`Mutex` is `Sync`); the RFC 1928 /// 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 -/// 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). struct SyncHalves { inner: std::sync::Mutex>, @@ -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 /// `std::sync::Mutex` (the same no-unsafe Sync proof as -/// [`SyncHalves`] — `TunnelSession` and `WriteHalf` are +/// [`SyncHalves`]: `TunnelSession` and `WriteHalf` are /// `Send` but not `Sync`). Contention never occurs at runtime (one /// pump task); a poisoned lock unwraps via `into_inner`. 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 /// correlation handler): register on the SOCKS5 producer's serving /// 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 /// the same client fabricates (one token space per producer /// connection). /// /// 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; /// keep it alongside the client they ride. /// /// # Errors /// /// A duplicate `channels/tunnel/forwarded` op id on the registry (an -/// assembly bug, not a runtime condition) — the underlying -/// registration's `String` diagnostic, surfaced (convention 2: no +/// assembly bug, not a runtime condition), and it surfaces the +/// underlying registration's `String` diagnostic (convention 2: no /// panic path; the caller decides). pub fn register_forwarded_serving( core: &ChannelCore, @@ -673,56 +679,58 @@ pub fn register_forwarded_serving( /// The composed accept factory (ADR-007 variant 2, producer.md /// §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 -/// (`register_tunnel_bindfirst_openable` + `BindFirstInjections` — -/// per-listener ACL for free, hub-relayable). +/// (`register_tunnel_bindfirst_openable` + `BindFirstInjections`, +/// giving per-listener ACL for free, hub-relayable). /// /// 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, /// 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 -/// 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). /// 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 /// correlation registry and returns the accept future parked on /// it. /// /// 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 /// [`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 /// adopted channel's `BiStream` with the [`ForwardedOwner`] (the -/// adopted channel session) riding inside the write payload — dropping -/// the handle drops the session AND the halves together. +/// adopted channel session) riding inside the write payload, so +/// dropping the handle drops the session AND the halves together. /// /// **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 -/// [`TunnelListener`] (its held write half EOFs the listen channel — -/// the far accept dispatcher tears down) AND drops the oneshot +/// [`TunnelListener`] (its held write half EOFs the listen channel, +/// so the far accept dispatcher tears down) AND drops the oneshot /// receiver (a later forwarded open for that token finds a dead -/// oneshot and drops the channel stream) — no leaked listener, no -/// leaked channel. +/// oneshot and drops the channel stream), leaving no leaked +/// listener and no leaked channel. /// /// Only TCP-stream listen resources are supported (the SOCKS5 BIND -/// data plane is raw on both ends; a UDP listen substrate would frame -/// — a datagram BIND variant is a different task). A non-TCP `bound` +/// data plane is raw on both ends; a UDP listen substrate would +/// frame, and a datagram BIND variant is a different task). A +/// non-TCP `bound` /// fails the factory (`NotAllowed`). /// /// **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, /// the same code the gate uses) therefore means the *tunnel* layer's /// 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( client: Arc, registry: CorrelationRegistry, @@ -746,12 +754,12 @@ pub fn bindfirst_accept( .map_err(|e| BindAcceptFactoryError::General(format!("bound render: {e}")))?, Some(SubstrateAddr::Path { path }) => { 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 => { 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" .into(), )); @@ -759,8 +767,8 @@ pub fn bindfirst_accept( }; let token = listener.listen().ok_or_else(|| { BindAcceptFactoryError::Shortage( - "the producer supplied no `listen` correlation token — not a bind-first \ - resource; no stand-in on this variant" + "the producer supplied no `listen` correlation token, so this is not a \ + bind-first resource; no stand-in on this variant" .into(), ) })?; @@ -771,8 +779,8 @@ pub fn bindfirst_accept( let fut = async move { // Force the whole-struct capture: edition-2021 disjoint // capture would take `listener.channel_id` (a `u32`) - // and drop the listener itself at the factory's return - // — EOF-ing the listen channel at birth. The listener + // and drop the listener itself at the factory's return, + // EOF-ing the listen channel at birth. The listener // rides the future end-to-end (the seam contract). let listener = listener; let ForwardedSession { read, write, peer } = match rx.await { @@ -784,7 +792,7 @@ pub fn bindfirst_accept( ); // The registry entry was removed without // 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 // channel down (the held write half EOFs). return Err(BindAcceptError::General( @@ -802,7 +810,7 @@ pub fn bindfirst_accept( inner: std::sync::Mutex::new(OwnedInner { // The listener rides INSIDE the payload: // 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 // lifetime; dropping the handle drops the // listener → the listen channel EOFs → @@ -810,7 +818,7 @@ pub fn bindfirst_accept( // structural, compiler-enforced). The // edition-2021 disjoint-capture trap (an // 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 // channel at birth) is why the whole // struct is rebound into the future above. @@ -832,7 +840,8 @@ pub fn bindfirst_accept( /// /// - IP literals resolve directly. /// - 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). /// /// The parse error is a `String` diagnostic (the factory maps it to @@ -844,7 +853,7 @@ fn resolve_bound(host: &str, port: u16) -> Result { } /// 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 /// address; the RFC sentinel `0.0.0.0:0` carries it (the data plane /// is unaffected either way). @@ -869,9 +878,9 @@ mod egress_unit_tests { use tokio::io::AsyncWriteExt; /// The reply-oversize guard fires (review 003 D-2): a reply whose - /// inner bytes exceed the joint bound is a `General` egress error - /// — the guard exists although the tunnel codec's u16 datagram - /// bound upstream should keep every real reply under it. + /// inner bytes exceed the joint bound is a `General` egress error, + /// though the guard exists in case the tunnel codec's u16 datagram + /// bound upstream fails to keep every real reply under it. #[test] fn inner_bytes_oversize_guard_fires() { 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 - /// shared queue closed with the egress — the stage exited - /// mid-relay — ends the relay's datagram pump silently (the + /// shared queue closed with the egress (the stage exited + /// mid-relay), so it ends the relay's datagram pump silently (the /// `tx.send(..).is_err()` early return), distinct from the EOF /// exit that still reports `Ended`. #[tokio::test] @@ -905,7 +914,7 @@ mod egress_unit_tests { server_side.shutdown().await.unwrap(); drop(server_side); 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. tokio::time::timeout(std::time::Duration::from_secs(5), handle) .await @@ -914,7 +923,7 @@ mod egress_unit_tests { } /// 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). #[tokio::test] async fn relay_read_reports_ended_on_eof() { @@ -937,7 +946,7 @@ mod egress_unit_tests { "a queued datagram: {first:?}" ); // 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; for _ in 0..3 { match rx.recv().await.expect("the queue delivers") { diff --git a/src/consumer.rs b/src/consumer.rs index 9cea84a..e981ef0 100644 --- a/src/consumer.rs +++ b/src/consumer.rs @@ -3,58 +3,59 @@ //! //! [`Socks5ChannelSession`] is the CONNECT session: it opens //! `channels/socks5/sub` via `ChannelClient::open_channel` (trivial -//! params — ADR-001), drives the forked client typestate's -//! `use_stream` path over the channel's `BiStream` (POC #1's fit — +//! params, ADR-001), drives the forked client typestate's +//! `use_stream` path over the channel's `BiStream` (POC #1's fit: //! `Socks5Stream` impls `AsyncRead + AsyncWrite` itself, so a session //! 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 -//! BIND session: the reply#2 reader (consumer.md §The BIND client, -//! ADR-007) — reply#1 rides the forked client `request()`, reply#2 is +//! BIND session and the reply#2 reader (consumer.md §The BIND client, +//! ADR-007); reply#1 rides the forked client `request()`, reply#2 is //! hand-read with the forked `read_address` codec (no second codec), //! 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 -//! C-1): the first `await_inbound` takes the stream — the plane owns -//! it, the session stays the teardown owner — and a second call +//! C-1): the first `await_inbound` takes the stream (the plane owns +//! it, the session stays the teardown owner), and a second call //! returns the family's spent shape (`Ok(None)`); the reply#2 reader //! is that one method alone (`reply1()` is a view, D-1). //! //! Target construction rides [`ToTargetAddr`]: IP-string forms parse -//! to IP targets; the domain form passes through unresolved (ADR-012 -//! — resolution is the egress's job, never the protocol's). +//! to IP targets; the domain form passes through unresolved (ADR-012; +//! resolution is the egress's job, never the protocol's). //! //! [`AssociateSession`] is the UDP ASSOCIATE session (POC #2's shape, //! consumer.md §The associate session, ADR-003): it opens the -//! channel, negotiates, sends ASSOCIATE, reads the reply — **receives -//! the sentinel `BND.ADDR` and never uses it for addressing** (the +//! channel, negotiates, sends ASSOCIATE, reads the reply, **receiving +//! the sentinel `BND.ADDR` and never using it for addressing** (the //! 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 //! incrementally with **reader state that persists across calls** -//! (mid-frame position + batched-datagram pending queue — a datagram -//! split across channel chunks survives a caller-driven receive -//! loop; POC #2's finding: the first cut dropped the reader state -//! per call, the 2-byte-prefix chunk test exposed it). `shutdown` -//! half-closes the stream — the mux's implicit-EOF sentinel reaches +//! (mid-frame position + batched-datagram pending queue, so a +//! datagram split across channel chunks survives a caller-driven +//! receive loop; POC #2's finding: the first cut dropped the reader +//! state per call, the 2-byte-prefix chunk test exposed it). `shutdown` +//! half-closes the stream; the mux's implicit-EOF sentinel reaches //! the producer's stage as EOF and the association ends (association //! lifetime = channel lifetime). //! //! Teardown ownership (alktunnels ADR-005 posture, inherited via //! ADR-004): the session owns the adopted channel entry. On Drop it -//! reaps it (`ChannelManager::teardown_channel` — sync, best-effort; -//! `Drop` cannot await) and the data plane EOFs structurally (the +//! reaps it (`ChannelManager::teardown_channel`, sync and +//! best-effort; `Drop` cannot await) and the data plane EOFs +//! structurally (the //! mux's implicit-EOF sentinel on the dropped write half). The //! teardown-at-birth discipline covers the in-stream refusal path: //! 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 struct): aliasing a session would alias its teardown and its //! reader state. //! //! 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 //! reply code surfaced through [`SocksSessionError::Rfc`]. @@ -76,11 +77,11 @@ use crate::rfc1928::{ReplyError, Socks5Command, SocksError, UdpHeaderError}; use crate::wire::{frame, DatagramReader, WireError}; /// The three session constructors' shared prologue (review 002 N-6): -/// open the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)` -/// — the trivial params, ADR-001), join the halves into the channel's +/// open the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`, +/// the trivial params, ADR-001), join the halves into the channel's /// `BiStream`, run the command negotiation over the forked client /// 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 /// id, the negotiated stream, and the cloned manager for the session /// to own. @@ -112,17 +113,17 @@ where /// The cred-bearing front door's re-origination prologue (the /// token-carrying twin of [`open_and_negotiate`]): open the channel /// 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 /// 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 /// the cloned manager. /// /// The token path needs the raw `call.requested` payload (`auth_token` /// is a payload-level field, ADR-017 §7), so the open rides -/// `CallConnection::call_with_payload` + `adopt_channel` — the same -/// two-step `open_channel` performs, with the token added. The +/// `CallConnection::call_with_payload` + `adopt_channel` (the same +/// two-step `open_channel` performs, with the token added). The /// client's call connection is taken once and cached in the returned /// client (the door's `TokenClient` owns it for the door's lifetime). async fn open_with_token_and_negotiate( @@ -157,7 +158,7 @@ where /// The spent-session marker (the gate review's R-2, /// tasks/implementation/review-protocol-core.md): [`Socks5ChannelSession /// ::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 /// session's teardown is already done (the reap ran with the take). #[derive(Debug, thiserror::Error)] @@ -183,9 +184,9 @@ pub struct Socks5ChannelSession { channel_id: u32, manager: Option, inner: Option>, - /// 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 - /// 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). reply_addr: Option, } @@ -201,15 +202,15 @@ impl std::fmt::Debug for Socks5ChannelSession { impl Socks5ChannelSession { /// CONNECT to `target` through a produced SOCKS5 resource: opens - /// the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)` - /// — the trivial params, ADR-001), drives the no-auth handshake + /// the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`, + /// the trivial params, ADR-001), drives the no-auth handshake /// and the CONNECT request in-stream over the channel's /// `BiStream`, and returns the session owning both. /// /// Error surface: an open failure is [`SocksSessionError::Open`] /// (the typed ADR-049 reasons; no channel was ever returned); a /// 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). pub async fn connect( client: &ChannelClient, @@ -233,8 +234,8 @@ impl Socks5ChannelSession { /// The cred-bearing front door's CONNECT re-origination (the /// token-carrying twin of [`Self::connect`]): the open rides the - /// payload `auth_token` (CF-005 precedence (1) — the producer's - /// identity provider resolves the door's mapped identity and its + /// payload `auth_token` (CF-005 precedence (1): the producer's + /// identity provider resolves the door's mapped identity, and its /// scopes gate the open), then the same in-stream negotiation over /// the channel's `BiStream`. Error surface identical. pub async fn connect_with_token( @@ -265,7 +266,7 @@ impl Socks5ChannelSession { 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()` /// returns it once). The front door's re-origination re-encodes /// the vanilla reply from it (the RFC codec at the door boundary @@ -275,8 +276,8 @@ impl Socks5ChannelSession { self.reply_addr.clone() } - /// The post-CONNECT pipe as split read/write halves (0 B framing - /// — raw bytes to/from the target). The halves borrow the + /// The post-CONNECT pipe as split read/write halves (0 B framing, + /// raw bytes to/from the target). The halves borrow the /// session: teardown stays owned (`close`/`Drop`). `None` after /// [`Self::into_inner`] took the pipe. pub fn split(&mut self) -> Option> { @@ -284,16 +285,16 @@ impl Socks5ChannelSession { Some(tokio::io::split(inner)) } - /// Take the underlying [`Socks5Stream`] (advanced use — + /// Take the underlying [`Socks5Stream`] (advanced use; /// the forked client type's socket accessors, owned-half pumps). /// The session rides along still owning the channel's teardown: - /// drop the pair (or `close` the session) when done — dropping - /// `inner` EOFs the data plane structurally. + /// drop the pair (or `close` the session) when done, since + /// dropping `inner` EOFs the data plane structurally. /// /// [`SpentSessionError`] on a spent session (the pipe already /// taken): a spent session's manager entry is always reaped /// 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 { match self.inner.take() { Some(inner) => Ok(TakenInner { @@ -317,9 +318,9 @@ impl Socks5ChannelSession { /// The adopted channel's teardown (`teardown_channel` returns a /// 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 - /// 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 /// abrupt path). fn reap(&mut self) -> bool { @@ -332,7 +333,7 @@ impl Socks5ChannelSession { impl Drop for Socks5ChannelSession { /// 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). /// Dropping without `close` never leaks the channel entry. fn drop(&mut self) { @@ -342,8 +343,8 @@ impl Drop for Socks5ChannelSession { } /// The UDP ASSOCIATE consumer session (POC #2's shape, consumer.md -/// §The associate session, ADR-003): the channel is the relay handle -/// — the reply's sentinel `BND.ADDR` is received and never used for +/// §The associate session, ADR-003): the channel is the relay handle; +/// the reply's sentinel `BND.ADDR` is received and never used for /// addressing, the datagram plane is the framed stage on the channel /// (`[len: u16 BE][addr + data]`, both directions), and the /// 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 /// incremental [`DatagramReader`] (mid-frame position) and the /// 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 /// first cut dropped the reader state per call). It does **not** /// implement `Clone`: @@ -370,17 +371,17 @@ pub struct AssociateSession { channel_id: u32, manager: Option, inner: Option>, - /// 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 /// across channel chunks; the same `DatagramReader` the producer /// stage runs). reader: DatagramReader, /// Datagrams decoded beyond the one returned (a chunk may batch - /// several datagrams — drained by later `recv_datagram` calls). - /// The batched datagrams are stored decoded (`(addr, payload)` — + /// several datagrams, drained by later `recv_datagram` calls). + /// The batched datagrams are stored decoded (`(addr, payload)`, /// the same shape `recv_datagram` returns). 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). relay_addr: Option, } @@ -398,13 +399,13 @@ impl std::fmt::Debug for AssociateSession { impl AssociateSession { /// 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 /// UDP ASSOCIATE request in-stream over the channel's `BiStream`, /// 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 - /// 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 /// (ADR-003: the handle is the channel). /// @@ -412,7 +413,7 @@ impl AssociateSession { /// (the typed ADR-049 reasons; no channel was ever returned); a /// refused ASSOCIATE (the missing-egress `CommandNotSupported` /// 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 /// phantom channel). pub async fn associate(client: &ChannelClient) -> Result { @@ -441,7 +442,7 @@ impl AssociateSession { /// The cred-bearing front door's ASSOCIATE re-origination (the /// token-carrying twin of [`Self::associate`]): the open rides /// 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 /// plane is the UDP door's task, ADR-003); the constructor is /// 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 /// 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). pub fn relay_addr(&self) -> Option { self.relay_addr.clone() @@ -490,10 +491,10 @@ impl AssociateSession { /// Send one datagram to `dst`: the RFC address encoding + the /// 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 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 /// length field would wrap) and the address-encoding refusals. pub async fn send_datagram( @@ -516,28 +517,28 @@ impl AssociateSession { } /// Receive the next datagram: decode incrementally from the - /// channel's read half — `(source_addr, payload)` per datagram, - /// `Ok(None)` on channel EOF (the association's end — the - /// association lifetime is the channel's, ADR-003). + /// channel's read half (`(source_addr, payload)` per datagram), + /// `Ok(None)` on channel EOF (the association's end, per the + /// association-lifetime contract, ADR-003). /// /// **The reader state persists across calls** (POC #2's finding, /// ADR-003): the incremental [`DatagramReader`]'s mid-frame - /// position survives a caller-driven receive loop — a datagram - /// split across channel chunks reassembles across calls — and + /// position survives a caller-driven receive loop (a datagram + /// split across channel chunks reassembles across calls), and /// frames decoded beyond the one returned (a chunk may batch /// several datagrams) queue in `pending` for later calls. /// /// Fail-loud shapes (never a silent partial datagram): a stream /// end mid-frame is the truncated-datagram condition (an /// `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 /// `UdpHeaderError` mapping and ends the caller's loop. pub async fn recv_datagram( &mut self, ) -> Result, SocksSessionError> { // 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() { return Ok(Some(dg)); } @@ -550,7 +551,7 @@ impl AssociateSession { loop { let n = socket.read(&mut chunk).await?; 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 // partial datagram; the reader state is the teardown // diagnostic). @@ -580,7 +581,7 @@ impl AssociateSession { /// The underlying stream as split read/write halves (the front /// 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 /// spent-session posture). pub fn split(&mut self) -> Option> { @@ -588,7 +589,7 @@ impl AssociateSession { Some(tokio::io::split(inner)) } - /// Take the underlying [`Socks5Stream`] (advanced use — + /// Take the underlying [`Socks5Stream`] (advanced use; /// the forked client type's socket accessors, owned-half pumps; /// the POC's `raw_mut` seam, widened). The session rides along /// still owning the channel's teardown: drop the pair (or @@ -597,7 +598,7 @@ impl AssociateSession { /// [`SpentSessionError`] on a spent session (the stream already /// taken): a spent session's manager entry is always reaped /// 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). pub fn into_inner(mut self) -> Result { match self.inner.take() { @@ -640,9 +641,9 @@ impl AssociateSession { /// The adopted channel's teardown (`teardown_channel` returns a /// 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 - /// 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 /// abrupt path). fn reap(&mut self) -> bool { @@ -655,8 +656,8 @@ impl AssociateSession { impl Drop for AssociateSession { /// Sync-reap the adopted channel entry (`teardown_channel` is - /// sync — best-effort; `Drop` cannot await) and drop the pipe - /// (the mux's implicit-EOF sentinel EOFs the producer's stage — + /// sync, best-effort; `Drop` cannot await) and drop the pipe + /// (the mux's implicit-EOF sentinel EOFs the producer's stage, /// the association ends with the session's lifetime). Dropping /// without `shutdown`/`close` never leaks the channel entry. fn drop(&mut self) { @@ -668,7 +669,7 @@ impl Drop for AssociateSession { /// The owned stream [`AssociateSession::into_inner`] yields, paired /// with the session that still owns the channel's teardown. pub struct TakenAssociateInner { - /// The teardown owner — reap via `shutdown`/`close`/`Drop`. + /// The teardown owner: reap via `shutdown`/`close`/`Drop`. pub session: AssociateSession, /// The framed datagram stage's stream (the forked client type /// over the channel's `BiStream`). @@ -678,7 +679,7 @@ pub struct TakenAssociateInner { /// The owned pipe [`Socks5ChannelSession::into_inner`] yields, paired /// with the session that still owns the channel's teardown. pub struct TakenInner { - /// The teardown owner — reap via `close`/`Drop`. + /// The teardown owner: reap via `close`/`Drop`. pub session: Socks5ChannelSession, /// The post-CONNECT pipe (the forked client type over the /// channel's `BiStream`). @@ -694,7 +695,7 @@ pub type SplitPipe<'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 -/// 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 /// listener; the composed variant's expected far-side address; 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 /// raw pre-reply#2 stream ([`Self::raw_mut`], the front-door bridge /// 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. /// -/// 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` /// (the compile-assert rides [`BindSession`]). pub struct BindReply1<'s> { inner: &'s mut Socks5Stream, - /// Reply#1's BND.ADDR — the advertised address. + /// Reply#1's BND.ADDR, the advertised address. pub bind_addr: TargetAddr, } @@ -725,11 +726,11 @@ impl BindReply1<'_> { } /// 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 -/// 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 -/// [`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 /// structurally (the mux's implicit-EOF sentinel). Accessors: /// [`Self::into_inner`] (owned) / [`Self::get_socket_mut`] @@ -737,7 +738,7 @@ impl BindReply1<'_> { pub struct BindDataPlane { inner: Socks5Stream, /// 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, } @@ -751,7 +752,7 @@ impl BindDataPlane { } /// 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 { 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 /// reply#1 (`bind`), then awaits reply#2 + the data plane /// (`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 /// factory failure's reply#1 code surfaces from `bind` as /// [`SocksSessionError::Rfc`] (the adopted channel is torn down -/// before the error returns — teardown-at-birth, the phantom-channel -/// property); accept-stage failures surface at `await_inbound` as -/// reply#2's mapped code ([`SocksSessionError::Rfc`]) — the channel +/// before the error returns; that is teardown-at-birth, the +/// phantom-channel property); accept-stage failures surface at +/// `await_inbound` as reply#2's mapped code +/// ([`SocksSessionError::Rfc`]); the channel /// is still open there (the listener existed; the session lives until /// the app closes it; the inner is restored, see the take-guard /// below). /// /// The reply#2 phase is take-guarded (review 002 -/// C-1): the first `await_inbound` takes the inner stream — the -/// returned plane owns it (the `TakenInner` pairing posture; the -/// session stays the teardown owner) — so a second call hits the +/// C-1): the first `await_inbound` takes the inner stream (the +/// returned plane owns it; the `TakenInner` pairing posture, the +/// session stays the teardown owner), so a second call hits the /// `None` arm and returns `Ok(None)`: the session family's spent /// shape, with no re-read of the live data plane possible. A failed /// read restores the inner (the phase is only consumed on success; /// 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 { channel_id: u32, manager: Option, inner: Option>, - /// 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 /// app server's out-of-band dial). bind_addr: Option, @@ -845,17 +848,17 @@ impl std::fmt::Debug for BindSession { impl BindSession { /// 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 /// 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 /// advertised address is [`BindReply1::bind_addr`]. /// /// Error surface: an open failure is [`SocksSessionError::Open`] /// (the typed ADR-049 reasons; no channel was ever returned); a - /// refused BIND — the command gate's `ConnectionNotAllowed` 0x02 - /// or a factory failure's mapped reply#1 code — surfaces the RFC + /// refused BIND (the command gate's `ConnectionNotAllowed` 0x02, + /// or a factory failure's mapped reply#1 code) surfaces the RFC /// reply code through [`SocksSessionError::Rfc`], and the adopted /// channel is torn down before the error returns (no phantom /// channel). @@ -881,7 +884,7 @@ impl BindSession { /// The cred-bearing front door's BIND re-origination (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 /// BIND command gate reads `socks5:bind` from it, ADR-006), then /// 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 - /// 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 /// front-door bridge seam). A **view**, not a reader (review 002 /// D-1's one-reader decision): the one normative reply#2 reader /// 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> { let inner = self.inner.as_mut()?; Some(BindReply1 { @@ -931,22 +934,22 @@ impl BindSession { /// Await the inbound accept (reply#2) and take the pass-through /// 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 - /// 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 - /// `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 /// rationale). /// /// `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 - /// 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 /// (`split`), and a re-call re-reads against the already-ended /// 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, SocksSessionError> { let Some(mut inner) = self.inner.take() else { return Ok(None); @@ -961,8 +964,8 @@ impl BindSession { } /// The pass-through pipe as split read/write halves (0 B framing - /// — raw bytes to/from the accepted peer). Valid in the reply#1 - /// phase (pre-reply#2 bytes ride the same channel — the + /// raw bytes to/from the accepted peer). Valid in the reply#1 + /// phase (pre-reply#2 bytes ride the same channel, the /// front-door bridge seam); post-[`Self::await_inbound`] the /// plane owns the stream (the take-guard), so the data plane's /// halves ride `BindDataPlane`/its `into_inner` instead. `None` @@ -985,9 +988,9 @@ impl BindSession { /// The adopted channel's teardown (`teardown_channel` returns a /// 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 - /// 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 /// abrupt path). fn reap(&mut self) -> bool { @@ -1000,7 +1003,7 @@ impl BindSession { impl Drop for BindSession { /// 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). /// Dropping without `close` never leaks the channel entry. fn drop(&mut self) { @@ -1012,14 +1015,15 @@ impl Drop for BindSession { /// Map a datagram-frame decode error ([`WireError`]) into the RFC /// vocabulary the session surface carries: the address-encoding /// 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` /// frame) surface as the RFC UDP-header error class -/// ([`UdpHeaderError`]) — the post-reply-garbage semantic, the -/// consumer-side mirror of the producer stage's +/// ([`UdpHeaderError`]), mirroring the post-reply-garbage semantic as +/// the consumer-side mirror of the producer stage's /// `UndecodableFrame` exit. The oversize family is unreachable on /// 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). fn wire_error(err: WireError) -> SocksSessionError { 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, -/// 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 /// `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). async fn read_reply2(inner: &mut Socks5Stream) -> Result where @@ -1060,16 +1064,16 @@ where /// The cred-bearing front door's channel surface (the /// token-carrying twin of a `ChannelClient`'s open path): wraps 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 /// `ServingConfig.identity_provider` and runs the ACL gate against the /// resolved identity, ADR-017 §7). The client's call connection is /// 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. /// /// 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 /// resolves `token → Identity` through its /// `ServingConfig.identity_provider`; the identity's scopes govern the @@ -1087,7 +1091,7 @@ impl TokenClient { /// /// [`TokenClientError::CallConnectionTaken`] when the client's /// 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). pub async fn wrap(client: &ChannelClient) -> Result { 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). pub fn manager(&self) -> &ChannelManager { &self.manager @@ -1109,9 +1113,9 @@ impl TokenClient { /// The raw open call with the `auth_token` payload (CF-005 /// precedence (1)): the payload shape mirrors /// [`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` - /// 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). pub async fn open_with_token(&self, token: Vec) -> Result { let payload = serde_json::json!({ @@ -1133,11 +1137,11 @@ impl TokenClient { } /// 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). #[derive(Debug, thiserror::Error)] 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, } @@ -1171,7 +1175,7 @@ mod tests { async fn spent_bind_await_inbound_returns_the_spent_shape() { // The C-1 take-guard's spent arm: a session whose inner is // 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 { channel_id: 9, manager: None, @@ -1209,7 +1213,7 @@ mod tests { inner: 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"); } @@ -1217,7 +1221,7 @@ mod tests { fn spent_session_into_inner_is_an_err_not_a_panic() { // The `expect` the review removed (R-2): the spent-session // 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 { channel_id: 9, manager: None, @@ -1270,7 +1274,7 @@ mod tests { pending: Default::default(), 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.relay_addr().is_none()); } @@ -1279,7 +1283,8 @@ mod tests { fn spent_associate_into_inner_is_an_err_not_a_panic() { // The `expect` the review removed (R-2): the spent-session // 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. let session = AssociateSession { channel_id: 9, @@ -1301,7 +1306,7 @@ mod tests { use tokio::io::AsyncWriteExt as _; // 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 // read batch. The session's reader state must survive the // call boundary: the first call returns the first datagram @@ -1338,7 +1343,7 @@ mod tests { assert_eq!(src, addr); 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()); } @@ -1412,7 +1417,7 @@ mod tests { #[tokio::test] async fn send_datagram_on_a_spent_stream_is_the_io_arm() { // 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. let mut session = AssociateSession { channel_id: 9, @@ -1440,9 +1445,9 @@ mod tests { ); // 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"); - assert!(!shutdown, "no manager — nothing reaped"); + assert!(!shutdown, "no manager, so nothing was reaped"); } #[tokio::test] @@ -1458,7 +1463,7 @@ mod tests { pending: Default::default(), relay_addr: None, }; - assert!(!session.close().await, "no manager — nothing reaped"); + assert!(!session.close().await, "no manager, so nothing was reaped"); } #[tokio::test] @@ -1466,8 +1471,9 @@ mod tests { use tokio::io::AsyncWriteExt as _; // The mid-batch later-frame `Err` arm (review 002 N-8): the - // first frame of a batch decodes `Ok`, a later one is garbage - // — the batch is discarded with the session (fail-loud, never + // first frame of a batch decodes `Ok`, a later one is + // garbage, and the batch is discarded with the session + // (fail-loud, never // a partial batch delivered while the session stays open). let (mut peer_writer, session_reader) = tokio::io::duplex(1024); 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 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 // error ended the caller's loop, the fail-loud posture). assert!(session.pending.is_empty()); @@ -1507,7 +1513,7 @@ mod tests { // The `Ok` arm of `AssociateSession::into_inner` (review 002 // N-8): the taken stream pairs with the session that stays 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. let (_write_peer, session_writer) = 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* // stream: the halves borrow the session and the raw framed - // bytes pass through both directions (0 B framing — the door's - // RFC header codec would own the frames at the boundary). + // bytes pass through both directions (0 B framing; the + // door's RFC header codec would own the frames at the + // boundary). let addr = TargetAddr::Ip("10.0.0.1:53".parse().unwrap()); 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"); 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. write.write_all(&framed).await.unwrap(); write.flush().await.unwrap(); @@ -1646,7 +1653,7 @@ mod tests { use tokio::io::AsyncWriteExt as _; // 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). let (mut peer_writer, session_reader) = tokio::io::duplex(1024); peer_writer.write_all(b"raw").await.unwrap(); @@ -1676,7 +1683,7 @@ mod tests { // The take-guard is untouched: the reader still reads the // 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. let outcome = session.await_inbound().await; assert!( @@ -1692,7 +1699,7 @@ mod tests { // The BIND door's half-close rides the plane's poll_shutdown // 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. let (_session_writer_unused, session_reader) = 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 - /// 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 /// raw). fn build_test_session(session_end: tokio::io::DuplexStream) -> AssociateSession { diff --git a/src/door.rs b/src/door.rs index 05b77af..3aa7b0c 100644 --- a/src/door.rs +++ b/src/door.rs @@ -1,5 +1,5 @@ //! 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, //! tun2proxy) point at. The door is the vanilla↔ACL boundary; the RFC //! 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 //! connection, a literal raw pipe onto a channel `BiStream` //! (`copy_bidirectional`, **zero protocol translation**). The -//! producer runs the whole RFC conversation through the pipe — the -//! door adds nothing. The loopback-only / wrapper-aware shape. +//! producer runs the whole RFC conversation through the pipe, and +//! the door adds nothing. The loopback-only / wrapper-aware shape. //! - **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` → //! `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** //! (`user:pass →` channel identity, ADR-005) and **re-originates //! the SOCKS conversation in-band** via the token-carrying consumer @@ -33,64 +33,66 @@ //! mapping yields an alkcall [`Identity`] (scopes included); the //! per-credential identity rides the open-op call's `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 -//! `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. //! The producer resolves it through its `ServingConfig //! .identity_provider` and the ACL gate reads the resolved scopes //! 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). //! //! **The accept path owns the idle defense** (ADR-014): an accepted //! 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 //! (an accept beyond the bound is refused door-side, no channel //! opened). The two v1 knobs are constructor config. //! //! **The BIND door**: the same listener shape; a vanilla BIND //! conversation rides [`BindSession`] (reply#1's advertised address -//! passes through to the vanilla client unchanged — the conversation -//! is the client's own; the door adds nothing, consumer.md §The BIND -//! door's reply#2 fidelity). The BIND door rides the same door -//! variant split (NoAuth raw-pipe vs cred-bearing re-origination). +//! passes through to the vanilla client unchanged, since the +//! conversation is the client's own; the door adds nothing, +//! consumer.md §The BIND door's reply#2 fidelity). The BIND door rides +//! the same door variant split (NoAuth raw-pipe vs cred-bearing +//! re-origination). //! //! Wiring (consumer.md §Wiring): `FrontDoor::builder() //! .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 + -//! isolated (the loop continues — the alktunnels per-accept isolation -//! posture). +//! isolated (the loop continues, per the alktunnels per-accept +//! isolation posture). //! //! **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 //! association. The RFC UDP-header codec lives ONLY here (ADR-003's //! codec-at-doors-only): inbound datagrams decode through //! `parse_udp_request` and re-send in-band as `{addr, data}` frames //! (`send_datagram`); replies encode through `new_udp_header` and go //! 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 //! 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 //! ends when the channel ends; the door's relay socket closes with //! it. The door-side idle bound (ADR-014): no datagrams from the //! client within the configured window closes the association (a -//! relay socket is not a channel — the cap regime does not bound -//! 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 +//! relay socket is not a channel; the cap regime does not bound +//! it). The reply's sentinel BND.ADDR is received and never used, +//! 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 //! door's ASSOCIATE reply (the assembly layer wires the two; the UDP //! door serves the datagram plane alone). The bind address is //! constructor config (consumer.md: "the UDP door's bind address is //! assembly-layer configuration"). Surface shape (recorded per the -//! task spec): a **sibling** `UdpFrontDoor` with its own builder — -//! the UDP door shares no accept loop with the TCP door (UDP has no -//! connections; one relay socket = one association) and the shared +//! task spec): a **sibling** `UdpFrontDoor` with its own builder; the +//! UDP door shares no accept loop with the TCP door (UDP has no +//! connections; one relay socket = one association), and the shared //! shape would have forced a mode split on every knob. use std::collections::HashMap; @@ -114,7 +116,7 @@ use crate::wire::{DatagramReader, WireError}; use bytes::Bytes; /// 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. #[derive(Clone, Debug)] pub struct DoorCredential { @@ -123,17 +125,17 @@ pub struct DoorCredential { /// The RFC 1929 password the vanilla client presents. pub password: String, /// 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 - /// `auth_token` (the resolution seam's vocabulary — the password + /// `auth_token` (the resolution seam's vocabulary: the password /// never rides it). pub identity: Identity, } /// The credential→identity static config table (ADR-005): exact /// `username`/`password` lookup. A miss rejects the RFC 1929 -/// conversation door-side (no channel opened — the ACL boundary held; -/// the vanilla client got the RFC 1929 rejection). +/// conversation door-side (no channel opened, so the ACL boundary is +/// held; the vanilla client got the RFC 1929 rejection). #[derive(Clone, Debug, Default)] pub struct CredentialTable { entries: HashMap<(String, String), Identity>, @@ -169,7 +171,7 @@ impl CredentialTable { } /// 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)] enum DoorExit { /// The vanilla client went away (clean end, both variants). @@ -177,7 +179,7 @@ enum DoorExit { /// The data plane (local pipe ↔ in-band session) ended. PlaneEnded, /// 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), /// The in-band open failed (the producer refused the mapped /// identity, the target dial refused, the channel open errored). @@ -245,10 +247,10 @@ impl FrontDoorBuilder { 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 /// 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 /// path). pub fn bind(mut self, addr: impl Into) -> Self { @@ -259,7 +261,7 @@ impl FrontDoorBuilder { /// The idle-first-byte window (ADR-014's accept-path defense /// knob 1): an accepted connection that sends no first byte /// 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 /// cred-bearing door's greeting (read by the door's own RFC 1929 /// half). @@ -269,11 +271,11 @@ impl FrontDoorBuilder { } /// The max-concurrent-*connections* cap (ADR-014's accept-path - /// defense knob 2; review 003 N-3's naming — the mechanism bounds - /// accepted connections, including ones past the idle gate that - /// are merely slow readers, not active RFC sessions): an accept - /// beyond the bound is refused door-side — the socket is closed - /// immediately, no channel opened. + /// defense knob 2; review 003 N-3's naming, for the mechanism + /// bounds accepted connections, including ones past the idle gate + /// that are merely slow readers, not active RFC sessions): an + /// accept beyond the bound is refused door-side; the socket is + /// closed immediately, with no channel opened. pub fn max_sessions(mut self, cap: usize) -> Self { self.max_sessions = Some(cap); self @@ -291,20 +293,22 @@ impl FrontDoorBuilder { } /// 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`). #[derive(Debug, thiserror::Error)] 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, } impl FrontDoor { /// Bind the configured address and run the accept loop /// (consumer.md §Wiring: `door.serve(channel_client)`). Per- - /// connection failures are logged + isolated — the loop continues - /// (the alktunnels per-accept isolation posture). Runs until the - /// listener fails fatally; the caller owns the task's lifetime. + /// connection failures are logged + isolated, and the loop + /// continues (the alktunnels per-accept isolation posture). Runs + /// until the listener fails fatally; the caller owns the task's + /// lifetime. pub async fn serve(self, client: Arc) -> std::io::Result<()> { let listener = TcpListener::bind(self.bind_addr).await?; tracing::info!(bind = %self.bind_addr, "front door: listening"); @@ -312,7 +316,7 @@ impl FrontDoor { } /// 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( self, listener: TcpListener, @@ -327,7 +331,7 @@ impl FrontDoor { // transport, one call connection, many per-credential // identities riding the open-op `auth_token` payloads). The // 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). let token_client = match credentials { Some(_) => Some(Arc::new( @@ -348,11 +352,11 @@ impl FrontDoor { }; // 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 // guard). 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); continue; }; @@ -378,9 +382,10 @@ impl FrontDoor { } /// Serve one accepted connection through the door variant split: the -/// idle-first-byte gate first (ADR-014 knob 1 — the door's accept -/// path owns the defense; an accepted socket is not a channel), then -/// the variant. +/// idle-first-byte gate first (ADR-014 knob 1, since the door's +/// accept +/// path owns the defense, and an accepted socket is not a channel), +/// then the variant. async fn serve_connection( stream: TcpStream, peer: SocketAddr, @@ -392,7 +397,8 @@ async fn serve_connection( // The idle-first-byte defense: an accepted connection that sends // no first byte within the window is closed door-side (ADR-014's // 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]; match tokio::time::timeout(idle, stream.peek(&mut first)).await { Ok(Ok(_)) => {} @@ -404,14 +410,14 @@ async fn serve_connection( } 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). (None, _) => raw_pipe(stream, client).await, // The cred-bearing door: RFC 1929 terminates locally, // re-origination rides the token sessions. (Some(table), Some(tokens)) => cred_bearing(stream, client, table, tokens).await, - // Unreachable by construction (`serve_on` builds the pair) — - // the arm exists to keep the match exhaustive without a + // Unreachable by construction (`serve_on` builds the pair), + // so the arm exists to keep the match exhaustive without a // panic path (convention 2). (Some(_), None) => { 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_ /// client` shape): open a channel, then `copy_bidirectional` the -/// accepted socket onto the channel `BiStream` — the producer runs -/// the whole RFC conversation through the pipe; the door adds +/// accepted socket onto the channel `BiStream`, so the producer +/// runs the whole RFC conversation through the pipe; the door adds /// nothing. async fn raw_pipe(mut stream: TcpStream, client: &Arc) -> DoorExit { let (_channel_id, send, recv) = match client @@ -451,8 +457,8 @@ async fn raw_pipe(mut stream: TcpStream, client: &Arc) -> DoorExi /// 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 /// re-speaks them from the parsed target. The password never crosses -/// the channel as a SOCKS password (the in-band handshake is NoAuth — -/// the mapped identity is the authorization); the channel identity +/// the channel as a SOCKS password (the in-band handshake is NoAuth, +/// so the mapped identity is the authorization); the channel identity /// never reaches the vanilla client. async fn cred_bearing( mut stream: TcpStream, @@ -469,7 +475,7 @@ async fn cred_bearing( // The in-band re-origination: the per-credential identity rides // 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. let token = identity.id.as_bytes().to_vec(); @@ -486,7 +492,7 @@ async fn cred_bearing( Err(err) => { // The in-band refusal's mapped code is re-encoded to // 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. write_refusal_reply(&mut stream, &err).await; return DoorExit::Open(err.to_string()); @@ -508,8 +514,9 @@ async fn cred_bearing( pipe_bind_plane(stream, session).await } // UDP ASSOCIATE through the TCP door is the UDP door's shape - // (the datagram plane's RFC codec boundary — ADR-003), not - // this one's: refuse RFC-faithfully — the vanilla client gets + // (the datagram plane's RFC codec boundary, ADR-003), not + // this one's: refuse RFC-faithfully, so the vanilla client + // gets // the refused reply through the door's own codec (no phantom // data plane, no in-band session opened). 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(&[ /// PasswordAuthentication])` → `read_username_password` → check /// through the static table → `accept`/`reject`, then @@ -538,7 +545,8 @@ async fn local_rfc1929_half( ) -> Result<(Socks5Command, TargetAddr, Identity), DoorExit> { // The check closure is sync (`FnMut(String, String) -> bool` over // 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). let found: Arc>> = Arc::new(std::sync::Mutex::new(None)); let found_for_check = Arc::clone(&found); @@ -547,7 +555,7 @@ async fn local_rfc1929_half( stream, |user, pass| { 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 // alive (AGENTS.md convention 2). 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 -/// 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. /// The door already consumed the vanilla client's greeting + command /// bytes; the post-auth bytes ride the session's data plane raw from /// here. The pump shape is the taken inner stream -/// (`Socks5Stream` is itself `AsyncRead + AsyncWrite` — the +/// (`Socks5Stream` is itself `AsyncRead + AsyncWrite`, the /// forked client type's intended use) against the local socket via /// `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). async fn pipe_connect_plane( mut stream: TcpStream, session: crate::consumer::Socks5ChannelSession, ) -> DoorExit { // 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 // fresh and re-originated). The sentinel address is the // 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 /// variant): reply#1's advertised address is re-encoded to 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 /// session's reply#1 address passes through re-encoded; consumer.md /// §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, ) -> DoorExit { // 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 Some(bind_addr) = bind_addr else { 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 /// advertised address: an IP BND.ADDR rides the codec (`new_reply`); /// 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 /// is pinned by the unit table, review 003 N-8). fn bind_reply1_wire(bind_addr: &TargetAddr) -> Vec { @@ -673,10 +683,10 @@ fn bind_reply1_wire(bind_addr: &TargetAddr) -> Vec { } /// 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 /// 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 /// teardown owner, dropped at the end). async fn bind_quiet_and_pipe( @@ -688,9 +698,9 @@ async fn bind_quiet_and_pipe( Ok(None) => return DoorExit::PlaneEnded, // An accept-stage refusal (the mapped reply#2 code): the // vanilla client gets that code re-encoded door-side (the RFC - // codec at the boundary — never a bare EOF), then the - // connection closes — the conversation ends where the in-band - // one did. + // codec at the boundary, never a bare EOF), then the + // connection closes, and the conversation ends where the + // in-band one did. Err(err) => { write_refusal_reply(&mut stream, &err).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 -/// boundary — ADR-003): write + flush; a failed write is the client +/// boundary, ADR-003): write + flush; a failed write is the client /// gone. async fn write_vanilla_reply(stream: &mut TcpStream, reply: &[u8]) -> std::io::Result<()> { 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 /// (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 -/// 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 /// shape (open/transport/I-O) stays a bare close (nothing to encode). /// 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 -/// 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 /// boundary (ADR-003). pub struct UdpFrontDoor { @@ -753,7 +763,7 @@ pub struct UdpFrontDoor { /// door's [`DoorExit`], for the datagram plane). #[derive(Debug)] 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). AssociationEnded, /// 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 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. #[derive(Default)] pub struct UdpFrontDoorBuilder { @@ -792,7 +802,7 @@ pub struct 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). pub fn bind(mut self, addr: impl Into) -> Self { self.bind_addr = Some(addr.into()); @@ -820,7 +830,7 @@ impl UdpFrontDoor { /// Bind the configured relay address and serve the association /// over it. Runs until the association ends (the in-band channel /// 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. pub async fn serve(self, client: Arc) -> std::io::Result<()> { let socket = UdpSocket::bind(self.bind_addr).await?; @@ -829,17 +839,17 @@ impl UdpFrontDoor { } /// 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 /// **IP** is learned from the first inbound datagram and pinned for /// the association's lifetime; replies go to the **latest-seen /// port** from that IP (a NAT port rebind under a stable IP keeps /// 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 /// 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, /// revisitable per OQ-SK2-09. The loopback bind is the documented /// posture; a wildcard-bind deployment accepts the same-IP @@ -853,8 +863,8 @@ impl UdpFrontDoor { let mut association = AssociateSession::associate(client.as_ref()) .await .map_err(|err| std::io::Error::other(err.to_string()))?; - // The reply's sentinel BND.ADDR is received and never used — - // the handle is the channel (ADR-003). The real relay address + // The reply's sentinel BND.ADDR is received and never used, + // since the handle is the channel (ADR-003). The real relay address // is this door's own bound socket; the vanilla client learns // it from the TCP door's ASSOCIATE reply (assembly-layer // wiring). @@ -862,17 +872,18 @@ impl UdpFrontDoor { // The datagram pump rides the association's split stream // halves: one pump owns the in-band write half (socket→ - // channel), the other the read half (channel→socket) — no - // shared association lock (a mutex over the whole session - // would deadlock: the channel read parks holding it while the - // socket side's send needs it). The pump is inlined (the + // channel), the other the read half (channel→socket), so no + // shared association lock holds (a mutex over the whole + // session would deadlock: the channel read parks holding it + // while the socket side's send needs it). The pump is inlined + // (the // halves borrow the association; the borrow ends before the // teardown below). let Some((mut channel_read, mut channel_write)) = association.split() else { return Ok(()); }; 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 // refreshes to the latest-seen source port from that IP (the // 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)); // 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 - // 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}` // in-band. Each datagram resets the idle window. let s2c = { @@ -944,8 +955,8 @@ impl UdpFrontDoor { // latest-seen reply port. The association's end (the channel // EOF) is this pump's clean exit. The decode state (the // incremental reader + the batched-datagram pending queue) - // persists across pump iterations — the bridge owns it for the - // association's lifetime. + // persists across pump iterations, so the bridge owns it for + // the association's lifetime. let c2s = { let socket = Arc::clone(&socket); let client_peer = Arc::clone(&client_peer); @@ -984,7 +995,8 @@ impl UdpFrontDoor { let _ = association.shutdown().await; // A socket I/O failure is the door's fatal outcome (surfaced to // 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 { UdpDoorExit::Io(err) => Err(err), _ => 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). async fn send_inband( 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 -/// 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` /// and the batched-datagram pending queue across calls (a datagram /// 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 /// `AssociateSession::recv_datagram` owns). struct InbandBridge { @@ -1027,7 +1040,7 @@ impl InbandBridge { } /// 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 /// EOF (the association's end); fail-loud mid-frame (never a /// silent partial datagram). @@ -1071,7 +1084,7 @@ impl InbandBridge { } /// 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( bridge: &mut InbandBridge, read: &mut R, @@ -1083,7 +1096,8 @@ where } /// 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). fn wire_error(err: WireError) -> SocksSessionError { 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`). type AssociationStream = crate::rfc1928::client::Socks5Stream; @@ -1108,7 +1122,7 @@ mod tests { /// Drive one pump-shaped decode call: feed `bytes` from the peer /// 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( bridge: &mut InbandBridge, read: &mut R, @@ -1129,7 +1143,7 @@ mod tests { async fn batched_frames_queue_not_drop() { // Two frames batched into one feed: the first returns, the // 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). let (mut writer, reader) = tokio::io::duplex(1024); let (mut channel_read, _channel_write) = tokio::io::split(reader); @@ -1153,7 +1167,8 @@ mod tests { #[tokio::test] async fn eof_mid_frame_fails_loud_not_silent() { // 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. let (mut writer, reader) = tokio::io::duplex(1024); let (mut channel_read, _channel_write) = tokio::io::split(reader); @@ -1183,7 +1198,7 @@ mod tests { async fn partial_frame_state_persists_across_calls() { // One chunk carries frame 1 complete + the first bytes of // 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 // reader would report a clean Ok(None) EOF here). let (mut writer, reader) = tokio::io::duplex(1024); @@ -1222,8 +1237,8 @@ mod refusal_and_wire_unit_tests { use alkcall::protocol::wire::CallError; use tokio::io::AsyncReadExt; - /// A connected TcpStream pair (the loopback listener shape — - /// `TcpStream` has no `pair` constructor; the listener dial is + /// A connected TcpStream pair (the loopback listener shape; + /// `TcpStream` has no `pair` constructor, so the listener dial is /// the portable way). async fn socket_pair() -> (tokio::net::TcpStream, tokio::net::TcpStream) { 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 /// hand-written wire code with no test): the IP form decodes /// 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. #[test] 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 - /// 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. #[tokio::test] 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; // Nothing written: the client's read finds no bytes (the bare - // close is the door's caller's act — the helper itself is - // silent). A short window proves no reply bytes exist. + // close is the door's caller's act, since the helper itself + // is silent). A short window proves no reply bytes exist. let mut probe = [0u8; 16]; let n = tokio::time::timeout( std::time::Duration::from_millis(200), @@ -1323,7 +1338,8 @@ mod bridge_later_batch_tests { use tokio::io::AsyncWriteExt; /// 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 /// error, the good frame is NOT silently delivered first). #[tokio::test] @@ -1333,7 +1349,7 @@ mod bridge_later_batch_tests { let addr = TargetAddr::Ip("10.0.0.7:53".parse().expect("addr")); let mut wire = crate::wire::frame(&addr, b"good").unwrap().to_vec(); // 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. wire.extend_from_slice(&[0xff, 0xff]); wire.extend_from_slice(b"short"); diff --git a/src/egress.rs b/src/egress.rs index c82a26e..8b45ad6 100644 --- a/src/egress.rs +++ b/src/egress.rs @@ -1,12 +1,13 @@ -//! The three I/O-plane seam types (ADR-004) — the dial seam (CONNECT), -//! the egress seam (UDP ASSOCIATE), the accept seam (TCP BIND) — as -//! function-shaped callbacks with their error vocabularies. These types -//! are the inversion point (AGENTS.md convention 7): the protocol layer +//! The three I/O-plane seam types (ADR-004), each a function-shaped +//! callback with its error vocabulary: the dial seam (CONNECT), the +//! egress seam (UDP ASSOCIATE), and the accept seam (TCP BIND). These +//! types are the inversion point (AGENTS.md convention 7): the +//! protocol layer //! never names a substrate type; the feature-gated providers //! (`local`/`composed`, batch 2) and the test harness construct them. //! //! **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 //! alktunnels ADR-004 finding, extended to all three SOCKS5 seams by //! POC #1/#2/#5). [`Egress`] is the one trait: the per-association @@ -19,29 +20,29 @@ //! - **Ownership rides the handle structurally** ([`TargetHandle`], //! [`BindTargetHandle`]): the payloads own their halves; any //! keep-alive state a substrate needs (e.g. a composed variant's -//! channel session) rides inside the halves — a provider-side owner -//! wrapper — so dropping the handle drops everything it dialed or +//! channel session) rides inside the halves (a provider-side owner +//! wrapper), so dropping the handle drops everything it dialed or //! accepted. No `_` bindings of owning values: the //! teardown-at-birth discipline, compiler-enforced by the shape. //! - **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 //! ([`crate::wire`]). //! - **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). //! - **The accept future owns its resources end-to-end** //! ([`BindAccept`]): the quiet wait's `select!` drop IS the //! client-gone teardown (ADR-004/ADR-007). //! - **Refusal is RFC-faithful**: every error type maps to RFC 1928 //! 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 //! is in-stream). //! //! The readiness-contract behavioral tests (an instant-`Ok(None)` -//! egress must not starve the datagram stage — including the -//! zero-yield shape, the gate review's R-1, +//! egress must not starve the datagram stage, including the +//! zero-yield shape (the gate review's R-1, //! tasks/implementation/review-protocol-core.md) live in the producer //! module's test suite, next to the stage they pin. This module //! 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; 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 /// structurally: the payloads own their connection halves; keep-alive /// 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 /// registration/assembly. Called once per CONNECT at the command-read /// interception point with the RFC 1928 target the client requested -/// (domain form passes through unresolved — ADR-012; resolution is the -/// provider's job). Per-target refusal is the returned -/// [`SocksDialError`] (the producer replies with the mapped RFC code — -/// no phantom data plane); session-level refusal stays channels-level. +/// (domain form passes through unresolved, per ADR-012; resolution is +/// the provider's job). Per-target refusal is the returned +/// [`SocksDialError`] (the producer replies with the mapped RFC code, +/// so no phantom data plane); session-level refusal stays channels-level. pub type DialFn = Arc< dyn Fn(TargetAddr) -> BoxFuture<'static, Result> + Send + Sync, >; /// 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). #[derive(Debug, thiserror::Error)] pub enum SocksDialError { @@ -109,7 +110,7 @@ impl SocksDialError { /// `NetworkUnreachable` → `NetworkUnreachable` (0x03), /// `HostUnreachable` → `HostUnreachable` (0x04), `NotAllowed` → /// `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 { match self { Self::Refused(_) => ReplyError::ConnectionRefused, @@ -122,9 +123,9 @@ impl SocksDialError { } /// 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 -/// 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). #[derive(Debug, thiserror::Error)] pub enum EgressError { @@ -140,7 +141,7 @@ impl EgressError { /// The RFC 1928 reply code for the ASSOCIATE refusal: `Refused` → /// `ConnectionRefused` (0x05), `Unreachable` → /// `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 { match self { 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 /// function-shaped; the instance it returns is a trait object, -/// `Box`, owned by the producer's datagram stage — +/// `Box`, owned by the producer's datagram stage; /// stage exit drops it, so egress state never outlives the channel). /// /// The stage never sees which substrate backs the association /// (convention 7 at the egress layer). pub trait Egress { /// 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 /// once, by the wire layer). A zero-length payload is a legal /// empty datagram, not a signal of any kind (the F-2 invariant). fn send(&mut self, addr: TargetAddr, payload: Bytes) -> BoxFuture<'_, Result<(), EgressError>>; - /// Yield the next inbound datagram's *inner* bytes (`addr + data` - /// — the RFC address encoding followed by the payload, exactly + /// Yield the next inbound datagram's *inner* bytes (`addr + data`, + /// the RFC address encoding followed by the payload, exactly /// [`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 /// caught. /// @@ -181,14 +182,14 @@ pub trait Egress { /// and a hot `Ok(None)` never yields to the channel branch (nor to /// the runtime's other tasks on a current-thread scheduler). /// `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 /// returns; the egress drops with it). fn next_reply(&mut self) -> BoxFuture<'_, Result, EgressError>>; } -/// The egress seam (ADR-004): the per-association egress factory — the -/// [`DialFn`] analogue for the datagram stage, injected at +/// The egress seam (ADR-004): the per-association egress factory (the +/// [`DialFn`] analogue for the datagram stage), injected at /// registration/assembly. Called once per UDP ASSOCIATE; the stage owns /// the returned egress; stage exit drops it. 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 /// structurally: the payloads own their accepted halves; keep-alive /// state a substrate needs (e.g. a composed variant's channel session) /// rides inside the halves (a provider-side owner wrapper), so the -/// compiler enforces the teardown contract — no `_` bindings of owning -/// values (the third teardown-at-birth witness, ADR-004). +/// compiler enforces the teardown contract, with no `_` bindings of +/// owning values (the third teardown-at-birth witness, ADR-004). pub struct BindTargetHandle { - /// The accepted connection's peer address (reply#2's BND.ADDR — - /// the RFC's "client IP and port of the incoming connection"). + /// The accepted connection's peer address (reply#2's BND.ADDR, the + /// RFC's "client IP and port of the incoming connection"). pub peer: TargetAddr, pub read: Box, pub write: Box, @@ -232,14 +233,14 @@ impl std::fmt::Debug for BindTargetHandle { } /// 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 /// future is awaited exactly once, in the producer's quiet-wait phase. /// /// **The accept future owns its resources end-to-end** (ADR-004/ /// ADR-007): whatever the provider opened (the listener, the tunnel) /// 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. pub struct BindAccept { bind_addr: SocketAddr, @@ -248,7 +249,7 @@ pub struct BindAccept { impl BindAccept { /// 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). pub fn new( bind_addr: SocketAddr, @@ -265,7 +266,7 @@ impl BindAccept { 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). pub async fn accept(self) -> Result { 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 -/// 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)] pub enum BindAcceptError { #[error("accept refused: {0}")] @@ -296,7 +297,7 @@ impl BindAcceptError { /// The RFC 1928 reply code for reply#2 on this refusal: `Refused` → /// `ConnectionRefused` (0x05), `NotAllowed` → /// `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 { match self { Self::Refused(_) => ReplyError::ConnectionRefused, @@ -307,7 +308,7 @@ impl BindAcceptError { } /// 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)] pub enum BindAcceptFactoryError { #[error("bind refused: {0}")] @@ -324,8 +325,8 @@ impl BindAcceptFactoryError { /// The RFC 1928 reply code for reply#1 on this refusal: `Refused` → /// `ConnectionRefused` (0x05), `NotAllowed` → /// `ConnectionNotAllowed` (0x02), `Shortage`/`General` → - /// `GeneralFailure` (0x01 — the RFC's catch-all; a resource - /// shortage has no dedicated code) — the POC #5 mapping, + /// `GeneralFailure` (0x01; the RFC's catch-all, since a resource + /// shortage has no dedicated code), the POC #5 mapping, /// table-tested in this module. pub fn to_reply_error(&self) -> ReplyError { 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 /// per TCP BIND with the requested target. The requested port is the /// only field a real bind can honor (domains are meaningless as bind /// 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 /// ([`BindAcceptFactoryError`]) → reply#1 with the mapped code; diff --git a/src/error.rs b/src/error.rs index 73808be..d1ed2d0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,10 +5,10 @@ //! [`SocksServerError`], [`ReplyError`]). //! //! 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 //! 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. use alkcall::channels::client::ChannelOpenError; @@ -17,7 +17,7 @@ pub use crate::rfc1928::server::SocksServerError; pub use crate::rfc1928::{ReplyError, SocksError}; /// 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` /// was ever returned). /// @@ -33,7 +33,7 @@ pub use crate::rfc1928::{ReplyError, SocksError}; /// [`ChannelOpenError`]. #[derive(Debug, thiserror::Error)] 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 /// (`channel:open_failed` + `details.reason` per ADR-049 §3 when /// the establisher refused; `FORBIDDEN`, `INVALID_INPUT`, @@ -52,7 +52,7 @@ impl SocksOpenError { /// The typed establishment-failure reason of a /// `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 /// shape: the registry's `FORBIDDEN`/`INVALID_INPUT` classes, /// `channel:too_many_channels`, or local-only failures @@ -65,7 +65,7 @@ impl SocksOpenError { /// The typed establishment-reason vocabulary (alkcall ADR-049 §3's /// 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. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SocksEstablishmentReason { @@ -83,7 +83,7 @@ pub enum SocksEstablishmentReason { impl SocksEstablishmentReason { /// 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 /// (the vocabulary is one-way; additive codes need a migration). pub fn parse(reason: &str) -> Option { @@ -117,8 +117,8 @@ impl std::fmt::Display for SocksEstablishmentReason { /// The consumer session's error surface (consumer.md §Error surface): /// open failures are channels-level and typed -/// ([`SocksOpenError`]); in-stream RFC failures (a refused target — -/// the reply code) are [`SocksSessionError::Rfc`]; the data-plane +/// ([`SocksOpenError`]); in-stream RFC failures (the refused-target +/// reply code) are [`SocksSessionError::Rfc`]; the data-plane /// codec failures are [`SocksSessionError::Rfc`]'s inner /// [`SocksError`] and [`SocksSessionError::Io`] is the plain I/O /// failure. @@ -131,7 +131,7 @@ pub enum SocksSessionError { /// The channel open failed (the session never came to exist). #[error("socks5 open failed: {0}")] 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 /// without `socks5:bind`), an auth rejection, a malformed /// negotiation, or the UDP-frame codec. The RFC-faithful surface: @@ -180,7 +180,7 @@ mod tests { #[test] 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). for code in ["FORBIDDEN", "INVALID_INPUT", "channel:too_many_channels"] { let err = SocksOpenError::Open(ChannelOpenError::CallFailed { @@ -215,7 +215,7 @@ mod tests { // Open plane: a refused session never yields a phantom channel. let open = SocksSessionError::from(open_failed_with("dial_failed")); 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. let refused = SocksSessionError::from(SocksError::ReplyError(ReplyError::ConnectionNotAllowed)); diff --git a/src/lib.rs b/src/lib.rs index 6200cac..4bd04c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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. //! //! A producer/consumer protocol crate (the alktty/alktunnels shape): @@ -6,7 +6,7 @@ //! drives the RFC 1928 conversation in-stream over the channel's //! `BiStream`; the consumer half opens SOCKS5 channels and speaks the //! 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. //! //! ## The landed surface (the protocol core) @@ -14,18 +14,18 @@ //! - **The wire vocabulary and the explicit typestate** live in //! [`rfc1928`]: a targeted fork of fast-socks5 v1.0.0's `T`-generic //! 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. //! - **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. //! - **The wire-stable open-op surface** ([`params`]): the //! `channels/socks5/sub` op id, the `alk/socks5` ALPN, the //! 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`]. //! - **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 //! egress readiness contract (`next_reply` `Pending` when nothing //! is ready) is load-bearing API. @@ -34,25 +34,25 @@ //! ASSOCIATE datagram stage / BIND phase model), the scope gates //! (the open gate `socks5:open` at the registry; the command gate //! `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. //! - **The consumer half** ([`consumer`]): the typed channels-native -//! sessions — [`Socks5ChannelSession`] (CONNECT), +//! sessions: [`Socks5ChannelSession`] (CONNECT), //! [`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 //! re-origination rides the token-carrying surface (`TokenClient` //! + the `*_with_token` constructor twins). //! //! The assembly layer (feature-gated, ADR-008/009): `local` (the -//! socket-backed providers — `local::tcp_dial` / `local::udp_egress` -//! / `local::bind_accept` — plus the producer-side -//! `local::ProducerDoor`) and `door` (the consumer's front doors — -//! the NoAuth raw-pipe + RFC 1929 cred-bearing `FrontDoor`, the +//! socket-backed providers, `local::tcp_dial` / `local::udp_egress` +//! / `local::bind_accept`, plus the producer-side +//! `local::ProducerDoor`) and `door` (the consumer's front doors: the +//! NoAuth raw-pipe + RFC 1929 cred-bearing `FrontDoor`, the //! `UdpFrontDoor` with the RFC codec at the door boundary only, //! ADR-005/003/014) ride `local`; the composed providers -//! (`composed::direct_egress` — the alktunnels direct-op egress, -//! `composed::bindfirst_accept` — the bind-first accept, +//! (`composed::direct_egress`, the alktunnels direct-op egress; +//! `composed::bindfirst_accept`, the bind-first accept; //! ADR-007/008) ride `composed`. The default crate is protocol-only //! and wasm-clean by construction. //! diff --git a/src/local/mod.rs b/src/local/mod.rs index 83324b9..4ef87e2 100644 --- a/src/local/mod.rs +++ b/src/local/mod.rs @@ -1,10 +1,10 @@ //! The `local` feature's socket-backed seam providers (ADR-008's local //! 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 //! constructors over the landed seam types ([`crate::egress`]); the //! 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 //! 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 @@ -13,24 +13,24 @@ //! The seam contracts each constructor honors (the landed types' //! 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. -//! - **The egress speaks inner bytes** — [`Egress::send`] takes the +//! - **The egress speaks inner bytes**: [`Egress::send`] takes the //! payload only; [`Egress::next_reply`] returns //! `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 //! 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 //! readiness), then `try_recv_from`; `WouldBlock` → the //! 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 //! the client-gone teardown. //! //! 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. use std::io::ErrorKind; @@ -49,9 +49,9 @@ use crate::{ }; /// 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 -/// [`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 /// wins, upstream parity). pub(crate) async fn resolve_target(addr: &TargetAddr) -> Result { @@ -71,7 +71,7 @@ pub(crate) async fn resolve_target(addr: &TargetAddr) -> Result SocksDialError { 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 /// `TcpStream` per CONNECT target; domain targets resolve here (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 /// map to [`SocksDialError`] faithfully ([`dial_error_of`]); /// `NotAllowed` is never synthesized by this provider. @@ -136,12 +136,12 @@ async fn local_tcp_dial(target: TargetAddr) -> Result EgressFactory { Arc::new(|| { @@ -161,7 +161,7 @@ pub struct LocalEgress { impl LocalEgress { /// 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). pub async fn bind() -> Result { let sock = UdpSocket::bind("127.0.0.1:0") @@ -190,7 +190,7 @@ impl Egress for LocalEgress { Box::pin(async move { // A real async wait on the socket's receive readiness (a // 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. sock.readable() .await @@ -198,7 +198,7 @@ impl Egress for LocalEgress { let mut scratch = vec![0u8; 65535]; match sock.try_recv_from(&mut scratch) { 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 // prefix is the wire layer's, applied exactly // once downstream. @@ -209,7 +209,7 @@ impl Egress for LocalEgress { Ok(Some(Bytes::from(inner))) } // 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). Err(e) if e.kind() == ErrorKind::WouldBlock => Ok(None), 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 /// active mode); port 0 binds an ephemeral port; `local_addr()` is /// 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); /// `accept()`'s peer is reply#2's [`BindTargetHandle::peer`] /// (byte-exact). Factory failures map to [`BindAcceptFactoryError`] @@ -231,7 +231,7 @@ impl Egress for LocalEgress { /// accept-stage failures → [`BindAcceptError`]. /// /// **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. pub fn bind_accept(bind_host: impl Into) -> BindAcceptFactory { let bind_host = bind_host.into(); diff --git a/src/local/producer_door.rs b/src/local/producer_door.rs index 5355ea2..1814bef 100644 --- a/src/local/producer_door.rs +++ b/src/local/producer_door.rs @@ -1,41 +1,41 @@ //! The producer-side local backend (feature `local`; producer.md //! §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 //! ssh `-D` capability hosted on the producer side). Each accepted //! 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 //! (`tcp_dial()` / `udp_egress()` / `bind_accept()`). No new //! protocol-layer code: this module is pure accept-loop assembly. //! //! **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 //! door-side protocol half to re-originate. A cred-bearing variant //! (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). //! //! **The accept path owns the idle defense + the session cap** //! (ADR-014's accept-path defense; an accepted socket is not a -//! channel — the cap regime does not bound it): the idle-first-byte -//! peek (non-consuming — the producer's own greeting read picks the +//! channel; the cap regime does not bound it): the idle-first-byte +//! peek (non-consuming, as the producer's own greeting read picks the //! byte up) and the max-concurrent-sessions cap (an accept beyond //! the bound is refused door-side, the socket closed immediately, no //! session spawned) are the same two knobs the consumer door carries //! ([`crate::door::FrontDoor`]). //! //! **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** -//! 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 //! builder's explicit `bind_policy(bool)` and the two accept-path //! knobs. //! //! **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 //! 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 @@ -51,13 +51,13 @@ use tokio::sync::{watch, Semaphore}; use crate::producer::Socks5Producer; /// 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). #[derive(Debug)] enum BackendExit { /// The vanilla client went away before/after the greeting. 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). Session(crate::SocksError), /// 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 -/// `TcpListener` front door for [`Socks5Producer`] — each accepted +/// `TcpListener` front door for [`Socks5Producer`]; each accepted /// TCP connection is served via `serve_socks5_over` (the NoAuth /// in-stream RFC conversation; the seams dial locally). The accept /// 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 -/// 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. #[derive(Default)] pub struct ProducerDoorBuilder { @@ -107,16 +107,16 @@ pub struct ProducerDoorBuilder { impl ProducerDoorBuilder { /// 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). pub fn producer(mut self, producer: Socks5Producer) -> Self { self.producer = Some(producer); 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 - /// is the assembly layer's spelled-out choice — loopback is the + /// is the assembly layer's spelled-out choice, with loopback the /// documented posture). pub fn bind(mut self, addr: impl Into) -> Self { self.bind_addr = Some(addr.into()); @@ -126,7 +126,7 @@ impl ProducerDoorBuilder { /// The idle-first-byte window (ADR-014 knob 1): an accepted /// connection that sends no first byte within this window is /// 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). pub fn idle_first_byte(mut self, window: Duration) -> Self { self.idle_first_byte = Some(window); @@ -134,10 +134,10 @@ impl ProducerDoorBuilder { } /// 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 /// 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. pub fn max_sessions(mut self, cap: usize) -> Self { self.max_sessions = Some(cap); @@ -158,20 +158,20 @@ impl ProducerDoorBuilder { } /// 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). #[derive(Debug, thiserror::Error)] 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, } /// 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). #[derive(Debug, thiserror::Error)] 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, #[error("tcp bind: {0}")] Bind(#[source] std::io::Error), @@ -179,7 +179,7 @@ pub enum ProducerDoorError { /// The serving guard: [`ProducerDoor::serve`] returns one; `close()` /// 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 /// loop task best-effort (the alktunnels `TunnelListener` teardown /// posture). @@ -190,12 +190,12 @@ pub struct ProducerDoorGuard { impl ProducerDoorGuard { /// 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- /// connection isolation cuts both ways). Returns the loop task's /// 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; /// holding it across the await would deadlock the graceful stop. pub async fn close(mut self) -> Option { @@ -211,7 +211,7 @@ impl ProducerDoorGuard { impl Drop for ProducerDoorGuard { /// 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. fn drop(&mut self) { if let Some(task) = self.task.take() { @@ -222,7 +222,7 @@ impl Drop for ProducerDoorGuard { impl ProducerDoor { /// 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 /// lifetime is the caller's: the returned [`ProducerDoorGuard`] /// ends it (`close()` graceful; drop best-effort abort). @@ -246,7 +246,7 @@ impl ProducerDoor { } /// 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 { let ProducerDoor { producer, @@ -280,11 +280,11 @@ fn serve_on_parts( }; // 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 // serving task ends and drops its guard). 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); continue; }; @@ -306,9 +306,9 @@ fn serve_on_parts( } /// 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), -/// 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). async fn serve_accepted( stream: tokio::net::TcpStream, diff --git a/src/local/tests.rs b/src/local/tests.rs index 8dd95b7..bf2ed51 100644 --- a/src/local/tests.rs +++ b/src/local/tests.rs @@ -1,6 +1,6 @@ -//! The local providers' unit tests: real sockets — the echo TCP -//! server for [`super::tcp_dial`] round-trips, a real UDP echo for -//! the egress ([`super::LocalEgress`], the POC #2 shape — including +//! The local providers' unit tests: real sockets, including the echo +//! TCP server for [`super::tcp_dial`] round-trips, a real UDP echo for +//! the egress ([`super::LocalEgress`], the POC #2 shape, including //! the readiness contract's park-then-datagram shape), a real BIND //! accept ([`super::bind_accept`], reply#1/reply#2 carrying the real //! addresses), and the refusal mappings table-tested against the @@ -19,7 +19,7 @@ use crate::rfc1928::consts; 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 /// decode through the codec, not a hand-parse: the inner bytes are /// `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 -/// (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 { let sock = UdpSocket::bind("127.0.0.1:0").await.expect("bind"); 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 /// 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] async fn tcp_dial_round_trips_through_the_boxed_halves() { 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` -/// 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 /// echo server; the seam received it unresolved and dialed the /// resolved address. @@ -133,7 +133,7 @@ async fn tcp_dial_resolves_domain_targets_here() { } /// 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 /// code. #[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. #[tokio::test] 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 /// shape): `send` (payload-only) → the echo → `next_reply`'s inner -/// bytes carry the sender's address + the data (no length prefix — -/// framing-once; the wire layer adds it downstream). +/// bytes carry the sender's address + the data (no length prefix, as +/// framing happens once; the wire layer adds it downstream). #[tokio::test] async fn udp_egress_round_trips_inner_bytes_with_sender_address() { 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"); // 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; assert_eq!(src, dest); assert_eq!(&payload[..], b"query"); } /// 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 /// wait against a `sleep`: no reply → not resolved by the bound; the /// 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"); // 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 .send(TargetAddr::Ip(echo), Bytes::from_static(b"again")) .await @@ -261,7 +261,7 @@ async fn udp_egress_next_reply_parks_until_a_datagram_arrives() { 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 /// handle). #[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 -/// invariant at payload level) — round-trips through the real +/// invariant at payload level) and round-trips through the real /// socket. #[tokio::test] 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 -/// `send` with `Unreachable` → `NetworkUnreachable` (0x03) — the +/// `send` with `Unreachable` → `NetworkUnreachable` (0x03); the /// resolve failure is the egress's error vocabulary. #[tokio::test] 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 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). let mut client_to_echo = client; 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 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). #[tokio::test] 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 - // 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 want = free.local_addr().expect("local_addr"); 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 -/// `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 /// fresh factory on the same (now free) port re-binds. #[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 // 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 // at all, i.e. the listener was dropped, not leaked). 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); /// 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 /// kinds (`NetworkUnreachable`, `HostUnreachable`, `TimedOut`, /// `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 -/// surface the producer replies with — the split's whole purpose). +/// surface the producer replies with; that is the split's whole purpose). #[test] fn dial_vocabulary_reply_codes_table() { use crate::egress::SocksDialError; diff --git a/src/params.rs b/src/params.rs index 4c3656a..3820864 100644 --- a/src/params.rs +++ b/src/params.rs @@ -1,20 +1,20 @@ //! 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 //! 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 -//! **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 //! gate, not a dial. The empty-object schema with //! `additionalProperties: false` keeps `{}` valid forever: any future //! 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 //! one open op, selected in-stream at command dispatch (ADR-001); BIND //! 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. //! //! The typed open-error vocabulary ([`crate::error::SocksOpenError`], @@ -29,7 +29,7 @@ use alkcall::registry::spec::{ use serde::{Deserialize, Serialize}; 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). /// All three RFC 1928 commands ride this one op. Wire-stable. 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. pub const SOCKS5_ALPN: &str = "alk/socks5"; -/// The scope gating SOCKS5 opens (ADR-006): proxy egress — CONNECT -/// and UDP ASSOCIATE. The scope-string convention +/// The scope gating SOCKS5 opens (ADR-006): proxy egress (CONNECT +/// and UDP ASSOCIATE). The scope-string convention /// `:`. Stable once published. pub const SOCKS5_OPEN_SCOPE: &str = "socks5:open"; /// 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 /// proxy egress must not implicitly get ingress listeners. Checked /// 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. pub const SOCKS5_BIND_SCOPE: &str = "socks5:bind"; -/// The open-op input params (ADR-001): the trivial object. Wire-stable -/// — the shape is frozen (empty object, nothing required, nothing +/// The open-op input params (ADR-001): the trivial object. Wire-stable, +/// since the shape is frozen (empty object, nothing /// else); a future per-session option is an additive schema extension, /// never a format change. /// /// `deny_unknown_fields` is the loud-rejection posture: unknown fields /// 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. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct 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). pub fn as_json() -> serde_json::Value { json!({}) @@ -79,22 +79,22 @@ impl Socks5Params { /// `Sub`-typed, external, channel-open marker on the `alk/socks5` /// 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 /// extension rule); unknown fields are rejected loudly. -/// - Reply schema: `{"channel_id": , minimum 1}` — the +/// - Reply schema: `{"channel_id": , minimum 1}`, the /// `Sub`-typed operation reply. No extra reply fields in v1 (the /// establishment phase is a session gate and has nothing to /// project; alkcall's `with_reply_field` seam remains available -/// additively if a future need appears — ADR-001). -/// - `required_scopes = ["socks5:open"]` — the registry runs the ACL +/// additively if a future need appears (ADR-001). +/// - `required_scopes = ["socks5:open"]`: the registry runs the ACL /// before the open handler (ADR-006; the open gate is the /// boundary). /// /// `error_schemas` declare the `channel:open_failed` contract /// (alkcall ADR-049 §3/§4 + ADR-016): the establisher's refusal /// 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) /// plus the registry's own `FORBIDDEN` class (the ACL gate runs /// before the establisher; ADR-006). The wrapper's wire shape is @@ -139,11 +139,11 @@ pub fn socks5_open_spec() -> OperationSpec { vec![ErrorDefinition { code: "channel:open_failed".to_string(), 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 \ channel cap was hit (channel:too_many_channels), or the \ 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)." .to_string(), schema: open_failed_schema, @@ -160,7 +160,7 @@ pub fn socks5_open_spec() -> OperationSpec { .with_channel_open(ChannelOpenSpec::new(SOCKS5_ALPN)) .with_description( "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 /// `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 /// [`crate::error`] ([`crate::error::SocksEstablishmentReason`], the /// parsed view a consumer branches on via @@ -191,7 +191,7 @@ mod tests { assert_eq!(spec.op_type, OperationType::Sub); assert_eq!(spec.visibility, Visibility::External); // 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["additionalProperties"], json!(false)); assert!(spec.input_schema.get("required").is_none()); @@ -229,7 +229,7 @@ mod tests { #[test] 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). let v = json!({"resource": "sneaky"}); assert!(serde_json::from_value::(v).is_err()); @@ -270,7 +270,7 @@ mod tests { 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(); // 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. assert_eq!( reasons, diff --git a/src/producer.rs b/src/producer.rs index 9f8241a..15c2903 100644 --- a/src/producer.rs +++ b/src/producer.rs @@ -3,7 +3,7 @@ //! (typestate driving + the three seams), teardown. //! //! Layering (producer.md, the POC-validated shape): the establisher -//! is the **session gate** (`Establishment::default()` — the target +//! is the **session gate** (`Establishment::default()`: the target //! is unknown at open time; nothing to thread establisher→handler, //! POC #1's wire note), the open handler is the **RFC state machine + //! command dispatch** (the forked typestate, ADR-002), the I/O planes @@ -12,52 +12,50 @@ //! ## The establisher //! //! [`socks5_session_establisher`] validates/records the opener -//! identity (the CF-005/CF-006 seam — ADR-005: the producer side is -//! identity-only; authorization already happened at the open gate) +//! identity (the CF-005/CF-006 seam; per ADR-005 the producer side is +//! identity-only, authorization already happened at the open gate) //! and applies session-level policy. It returns -//! `Establishment::default()` — no plan payload: the dial happens -//! post-establishment, in-stream at command dispatch, so there is -//! nothing to thread establisher→handler. The optional -//! `identity_witness` probe seam (the alktunnels posture) records -//! the per-call opener identity for tests. +//! `Establishment::default()` and threads no plan payload: the dial +//! happens post-establishment, in-stream at command dispatch, so +//! there is nothing to thread establisher→handler. The optional //! //! A channels-level refusal is the registry ACL gate's job (it runs -//! before the establisher — `socks5:open`, ADR-006; the scope-less +//! before the establisher, per `socks5:open`/ADR-006; the scope-less //! identity fails closed with `FORBIDDEN`, no phantom channel). The -//! establisher itself never refuses — it is the identity-recording +//! establisher itself never refuses; it is the identity-recording //! session gate (ADR-005). //! //! ## The pump handler + dispatch //! //! [`make_socks5_pump_handler`] accepts the channel's `BiStream` -//! (`accept_bi` — the yield-once contract) and drives the forked +//! (`accept_bi`, the yield-once contract) and drives the forked //! typestate over it (`accept_no_auth → read_command`, ADR-005), then //! dispatches: //! //! - **CONNECT** → the dial seam ([`DialFn`], ADR-004) at the //! command-read interception point; success: `reply_success` then -//! `pump_bidi` **awaited inline** (R-02 — the returned `JoinHandle` +//! `pump_bidi` **awaited inline** (R-02: the returned `JoinHandle` //! tracks the data-plane lifetime; early return = teardown-at- //! birth). Refusal: `reply_error` with the mapped RFC code //! (dial-failure → `ConnectionRefused` 0x05; scope-class → -//! `ConnectionNotAllowed` 0x02) — no phantom data plane. +//! `ConnectionNotAllowed` 0x02), so no phantom data plane. //! - **UDP ASSOCIATE** → the datagram stage (ADR-003): the sentinel -//! reply (`127.0.0.1:0` — the relay handle IS the channel; the +//! reply (`127.0.0.1:0`: the relay handle IS the channel, the //! address is pure RFC-compatibility surface), then the //! `select!`-driven stage (wire.md §UDP ASSOCIATE): channel reads ∥ //! egress replies, framing applied exactly once by the wire layer, //! the egress readiness contract (`Pending` when nothing is ready), //! association lifetime = channel lifetime. Missing egress seam: -//! RFC-faithful refusal (`CommandNotSupported` — ADR-008's +//! RFC-faithful refusal (`CommandNotSupported`, ADR-008's //! missing-callback class). Egress-factory failure: reply#1 with //! the mapped code. //! - **TCP BIND** → the command gate first (`socks5:bind`, ADR-006, -//! from the per-call opener identity — a scope-less identity gets +//! from the per-call opener identity; a scope-less identity gets //! `ConnectionNotAllowed` 0x02 while CONNECT still serves it), then //! the phase model (ADR-007): the accept seam //! ([`BindAcceptFactory`], ADR-004) at the command-read //! interception point, reply#1 carrying BND.ADDR, the quiet wait -//! (single-shot, exactly two exits — the accept, or the control +//! (single-shot, exactly two exits: the accept, or the control //! stream's EOF/garbage/error), reply#2 carrying the peer address, //! then `pump_bidi` inline. Two-stage failure mapping: factory //! failure → reply#1's code; accept-stage failure → reply#2's @@ -66,7 +64,7 @@ //! accept future's instant resolution) + sentinel reply#2 //! (`0.0.0.0:0`). //! -//! The handler is substrate-agnostic by construction — it sees boxed +//! The handler is substrate-agnostic by construction; it sees boxed //! halves, never a socket type (AGENTS.md convention 7; the //! inversion point is the seam closure, ADR-004). //! @@ -76,20 +74,20 @@ //! (the POC shape): generic over `T: AsyncRead + AsyncWrite + Unpin`, //! driving the typestate and dispatching, with the `can_bind` gate //! carried explicitly (the open-op identity seam is consumed by the -//! pump handler — the substrate-free entry takes the resolved gate +//! pump handler; the substrate-free entry takes the resolved gate //! boolean). The channel `BiStream` and a `TcpStream` are equally -//! `T` — this is what makes the producer testable without channels +//! `T`; this is what makes the producer testable without channels //! (the differential-suite pattern) and reusable by the batch-2 //! local backend (feature `local`, ADR-009). The builder's shape //! resolves that gate from its explicit `bind_policy` (defaulting to -//! the accept seam's presence — never the sole gate; ADR-006's +//! the accept seam's presence; never the sole gate, per ADR-006's //! "listener configured" ≠ "listener permitted"). //! //! ## Registration //! //! [`register_socks5_openable`] wires spec + establisher + pump via //! `ChannelCore::register_openable_with_establisher` (`timeout: None` -//! = the 10s default bound — ADR-049 §2), scope-gated `socks5:open` +//! = the 10s default bound, ADR-049 §2), scope-gated `socks5:open` //! (the registry runs the ACL before the establisher). Missing //! callbacks = those commands refuse RFC-faithfully //! (`CommandNotSupported`-class), never a panic (ADR-008). The @@ -120,7 +118,7 @@ use crate::wire::{frame_inner, DatagramReader}; pub use crate::params::{OP_SOCKS5_OPEN, SOCKS5_ALPN, SOCKS5_OPEN_SCOPE}; /// The sentinel `BND.ADDR` the in-band reply carries (ADR-003): pure -/// RFC-compatibility surface — the relay handle IS the channel; +/// RFC-compatibility surface (the relay handle IS the channel); /// wrapper-aware clients never sendto() it (the datagram stage is /// in-band) and never bind() it (the accept variants own the inbound /// surface). `127.0.0.1:0` per wire.md §UDP ASSOCIATE. @@ -134,14 +132,14 @@ pub(crate) fn sentinel_reply_addr() -> SocketAddr { pub type IdentityWitness = Arc>>; /// The session-gate establisher: the open op's awaited phase. For the -/// SOCKS5 op it records the opener identity (the CF-005/CF-006 seam -/// — ADR-005; per-call `auth` is the dispatch-resolved identity +/// SOCKS5 op it records the opener identity (the CF-005/CF-006 seam, +/// ADR-005; per-call `auth` is the dispatch-resolved identity /// overlaid onto the install-time context) and applies session-level -/// policy. A channels-level refusal is the registry ACL gate's job — +/// policy. A channels-level refusal is the registry ACL gate's job; /// it runs before this ever runs (ADR-006). The RFC handshake is -/// in-stream, after this. Returns `Establishment::default()` — no -/// plan needed (the dial is in-stream, in the pump handler; POC #1's -/// wire note). +/// in-stream, after this. Returns `Establishment::default()`; no +/// plan is needed (the dial is in-stream, in the pump handler; POC +/// #1's wire note). pub fn socks5_session_establisher(witness: Option) -> OpenEstablisher { Arc::new(move |_input: Value, auth: AuthContext| { let witness = witness.clone(); @@ -155,14 +153,14 @@ pub fn socks5_session_establisher(witness: Option) -> OpenEstab } /// The pump handler: accept the channel's yield-once `BiStream` -/// (`accept_bi` first — the wrapper's accepted-flag telemetry), then +/// (`accept_bi` first, the wrapper's accepted-flag telemetry), then /// run the RFC 1928 state machine over it and dispatch on the /// command. The command gate rides the open-op `AuthContext`'s /// per-call opener identity (CF-005/CF-006): TCP BIND requires -/// `socks5:bind` (ADR-006) — CONNECT and UDP ASSOCIATE need only the -/// open gate. The handler is substrate-agnostic by construction — it +/// `socks5:bind` (ADR-006); CONNECT and UDP ASSOCIATE need only the +/// open gate. The handler is substrate-agnostic by construction; it /// sees boxed halves, never a socket type (ADR-004's inversion -/// point). Awaits the data plane inline (R-02 — the returned +/// point). Awaits the data plane inline (R-02: the returned /// `JoinHandle` tracks the data-plane lifetime; early return = /// teardown-at-birth). pub fn make_socks5_pump_handler( @@ -196,14 +194,14 @@ pub fn make_socks5_pump_handler( ) } -/// The RFC 1928 server conversation over any `T` — the substrate-free +/// The RFC 1928 server conversation over any `T`, the substrate-free /// protocol layer (the POC shape). `T` is the channel `BiStream`, a -/// `TcpStream`, a duplex half — the typestate and the seams never +/// `TcpStream`, a duplex half; the typestate and the seams never /// know. `dial`/`egress`/`accept` are the injected seams (ADR-004); /// a missing seam makes its command refuse RFC-faithfully -/// (`CommandNotSupported`-class, ADR-008) — never a panic. `can_bind` -/// is the command gate's resolved boolean (ADR-006 — the pump handler -/// resolves it from the per-call opener identity; a substrate-free +/// (`CommandNotSupported`-class, ADR-008), never a panic. `can_bind` +/// is the command gate's resolved boolean (ADR-006; the pump handler +/// resolves it from the per-call opener identity, while a substrate-free /// caller passes its policy's answer). pub async fn serve_socks5_over( stream: T, @@ -226,11 +224,11 @@ where Socks5Command::TCPBind => { if !can_bind { // The command gate (ADR-006): BIND mints inbound - // listeners — an identity with proxy egress must not + // listeners; an identity with proxy egress must not // imply ingress. RFC-faithful refusal (the command is // known, the caller is not allowed). The session // error is the log/observability vocabulary (the wire - // got its reply already) — `Refused`, the honest + // got its reply already), and `Refused` is the honest // shape (review 002 N-4; an ACL refusal is not an // auth failure). proto.reply_error(&ReplyError::ConnectionNotAllowed).await?; @@ -245,8 +243,8 @@ where /// CONNECT: dial at the command-read interception point (ADR-004), /// reply, then the pass-through data plane (`pump_bidi`, ADR-050's -/// two-pump shape, awaited inline — R-02). Refusal: `reply_error` -/// with the mapped RFC code — no phantom data plane. +/// two-pump shape, awaited inline per R-02). Refusal: `reply_error` +/// with the mapped RFC code, so no phantom data plane. async fn serve_connect( proto: Socks5ServerProtocol, target: TargetAddr, @@ -279,14 +277,14 @@ where } /// UDP ASSOCIATE: the datagram stage (ADR-003, POC #2-validated). -/// The typestate is driven directly (no `run_udp_proxy_custom` — its -/// unconditional kernel bind is unusable on channels); the reply +/// The typestate is driven directly (no `run_udp_proxy_custom`, since +/// its unconditional kernel bind is unusable on channels); the reply /// carries the sentinel address (the relay handle IS the channel; /// the address is pure RFC-compatibility surface for wrapper-aware /// clients, who never sendto() it); the post-reply stream is the /// framed `{addr, data}` datagram stage. A missing egress seam -/// refuses RFC-faithfully (ADR-008's missing-callback class — an -/// unprovided capability, never a panic); an egress-factory failure +/// refuses RFC-faithfully (an unprovided capability per ADR-008's +/// missing-callback class, never a panic); an egress-factory failure /// refuses with the mapped code. async fn serve_udp_associate( proto: Socks5ServerProtocol, @@ -321,19 +319,19 @@ where /// The post-reply datagram stage (wire.md §UDP ASSOCIATE; the POC #2 /// shape). Three POC invariants, load-bearing: /// -/// - **The concurrent select shape** — channel reads and the egress's +/// - **The concurrent select shape**: channel reads and the egress's /// reply stream run concurrently (the honest full-duplex shape; a /// poll-then-block loop loses replies arriving while the channel -/// read is parked — the POC's first iteration, measured). +/// read is parked; that was the POC's first iteration, measured). /// - **The egress readiness contract** (`Egress::next_reply`): it /// must be `Pending` when no reply is ready; an instantly-ready /// `Ok(None)` starves a biased select into livelock. The stage /// tolerates a spurious `Ok(None)` by yielding once and re-parking -/// (never a hot loop — and never a zero-yield hot spin on a +/// (never a hot loop, and never a zero-yield hot spin on a /// current-thread scheduler, the livelock a pure `async { Ok(None) /// }` egress reproduces); an `Err` ends the association (the egress /// drops with it). -/// - **Framing applied exactly once, by the wire layer** — the stage +/// - **Framing applied exactly once, by the wire layer**: the stage /// reads frames via `DatagramReader` (channel → egress `send` gets /// the payload) and writes frames via `frame_inner` (egress /// `next_reply`'s inner bytes → the channel). The egress never sees @@ -341,31 +339,31 @@ where /// /// Association lifetime = channel lifetime: channel EOF ends the /// stage; no extra control vocabulary. A undecodable frame (garbage -/// ATYP — any post-reply control byte) fails the association loud +/// ATYP: any post-reply control byte) fails the association loud /// (the RFC's "post-reply control data is garbage" semantic). A -/// stream end mid-frame is fail-loud too (never a silent partial -/// datagram — the reader state is the teardown diagnostic). The +/// stream end mid-frame is fail-loud too (the reader state is the +/// teardown diagnostic). The /// stage owns the egress: on every exit path the egress binding -/// drops here (teardown-at-birth — no `_` bindings of owning +/// drops here (teardown-at-birth, no `_` bindings of owning /// values). /// /// Per-datagram addressing in the frame does the demultiplexing (no /// flow table); one relay loop, no spawn-per-datagram. A frame's -/// egress `send` is awaited inline before the next select iteration — -/// the stage interleaves egress sends with reply drains (the POC's +/// egress `send` is awaited inline before the next select iteration, +/// so the stage interleaves egress sends with reply drains (the POC's /// measured shape; sufficient for relay postures; a slow egress -/// applies backpressure to the association, never to other channels — +/// applies backpressure to the association, never to other channels; /// wire.md §UDP ASSOCIATE). /// /// Residual posture (review 002 S-1, named decision): the `Ok(None)` -/// tolerance's defense is bounded by `yield_now` only — a *misbehaving* +/// tolerance's defense is bounded by `yield_now` only; a *misbehaving* /// egress returning `Ok(None)` at full speed degrades the stage into /// a yielding busy-wait (making no progress, unbounded by rate; the /// runtime survives, the stage never parks). A compliant egress parks /// (the readiness contract); a backoff/budget in the stage would /// penalize the compliant path, so the residual is bounded by the -/// egress's own correctness and accepted-uncovered — named here, not -/// accidental. +/// egress's own correctness and accepted-uncovered (named here, not +/// accidental). async fn run_datagram_stage(mut inner: T, egress: &mut (dyn Egress + Send)) -> StageExit where T: AsyncRead + AsyncWrite + Unpin, @@ -376,13 +374,13 @@ where loop { tokio::select! { // The egress's reply stream (its own pump; `None` = a - // spurious wakeup — the stage re-parks, never a hot + // spurious wakeup; the stage re-parks, never a hot // loop). The yield makes the tolerance safe against a // contract-violating zero-yield egress too: without it a // pure `async { Ok(None) }` starves the channel branch // and the runtime's other tasks on a current-thread // scheduler (an instantly-ready branch that yields - // nothing wins every poll — the readiness contract's POC + // nothing wins every poll; the readiness contract's POC // #2 finding, hardened by the gate review's R-1 // (tasks/implementation/review-protocol-core.md)). reply = egress.next_reply() => { @@ -410,7 +408,7 @@ where // The channel's read side: frames in, EOF = end. read = inner.read(&mut chunk) => { match read { - // Channel EOF = association end (the clean exit — + // Channel EOF = association end (the clean exit; // the association lifetime is the channel's). Ok(0) => return StageExit::ChannelEof, Ok(n) => { @@ -439,22 +437,22 @@ where } } -/// Why the datagram stage ended (the stage's teardown diagnostic — -/// each exit drops the egress with the stage; the channel EOF is the -/// one clean exit, per the association-lifetime contract). +/// Why the datagram stage ended (the stage's teardown diagnostic, +/// since each exit drops the egress with the stage; the channel EOF is +/// the one clean exit, per the association-lifetime contract). #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum StageExit { - /// Channel EOF — the association's clean end (the one the + /// Channel EOF: the association's clean end (the one the /// association-lifetime contract names). ChannelEof, - /// A frame the codec refused (garbage ATYP / a `len == 0` frame — - /// post-reply control bytes). Loud fail: never a silent drop. + /// A frame the codec refused (garbage ATYP / a `len == 0` frame, + /// i.e. post-reply control bytes). Loud fail: never a silent drop. UndecodableFrame, /// An egress `next_reply` `Err` or a `send` failure (the egress /// relay ended; the lossy-datagram posture). EgressEnded, /// A reply the wire codec rejected (the egress violated the - /// joint bound — never a wire overflow). + /// joint bound, so never a wire overflow). ReplyFrameRejected, /// The channel stream itself failed (I/O). Io, @@ -462,30 +460,30 @@ enum StageExit { /// TCP BIND: the phase model (ADR-007; POC #5-validated). Phases: /// -/// 1. **Accept factory** — the CONNECT dial's mirror, called at the +/// 1. **Accept factory**: the CONNECT dial's mirror, called at the /// command-read interception point with the requested target. A /// factory failure maps to reply#1 (no phantom listener). No /// factory wired = the wrapper-aware in-band default (ADR-007 -/// variant 3): a synthetic accept whose reply#1 is the sentinel — +/// variant 3): a synthetic accept whose reply#1 is the sentinel, /// the handle-swap reading applied to BIND (the reply is /// RFC-compatibility surface; the data plane is the channel). -/// 2. **Reply#1** — success + BND.ADDR (the factory's `bind_addr`: +/// 2. **Reply#1**: success + BND.ADDR (the factory's `bind_addr`: /// the real bound listener on the provider-backed variants, the /// sentinel on the wrapper-aware one). -/// 3. **The quiet wait** — single-shot `select!` with exactly two +/// 3. **The quiet wait**: single-shot `select!` with exactly two /// exits (the POC's `never_loop` finding): the one inbound accept -/// (the `BindAccept` future — its drop IS the client-gone +/// (the `BindAccept` future's drop IS the client-gone /// teardown of whatever the provider opened), or the control /// stream's EOF/garbage/error. Not a loop: any byte in the quiet /// wait is protocol garbage (a BIND client never sends another -/// control byte — RFC 1928 §3's post-reply semantic) → the +/// control byte, per RFC 1928 §3's post-reply semantic) → the /// session ends with no data plane. The wait lives in the /// teardown-governed pump phase, never under the establishment /// bound (FTP-length BIND waits stay legal). -/// 4. **Reply#2** — the peer address (the accepted handle's peer; +/// 4. **Reply#2**: the peer address (the accepted handle's peer; /// the wrapper-aware sentinel is `0.0.0.0:0`). An accept-stage -/// failure maps to reply#2's code — no data plane. -/// 5. **Data plane** — `pump_bidi` inline (the CONNECT shape, R-02): +/// failure maps to reply#2's code, so no data plane exists. +/// 5. **Data plane**: `pump_bidi` inline (the CONNECT shape, R-02): /// raw pass-through between the channel and the accepted halves. async fn serve_tcp_bind( proto: Socks5ServerProtocol, @@ -508,7 +506,7 @@ where None => in_band_bind_accept(), }; - // Reply#1: success + BND.ADDR — the factory's address is the + // Reply#1: success + BND.ADDR; the factory's address is the // carrier (real listener / advertised composed address / the // wrapper-aware sentinel). let inner = proto.reply_success(accept.bind_addr()).await?; @@ -519,7 +517,7 @@ where /// The no-factory in-band default (ADR-007 variant 3, wrapper-aware): /// the sentinel reply#1 rides a synthetic accept whose future -/// resolves instantly — the phase model is preserved end to end +/// resolves instantly, so the phase model is preserved end to end /// (reply#1 → quiet wait → reply#2 → pass-through) with the channel /// itself as the accepted "connection". Reply#2's peer is the /// wrapper-aware sentinel `0.0.0.0:0`. @@ -536,8 +534,8 @@ fn in_band_bind_accept() -> BindAccept { /// The quiet wait + reply#2 + the data plane. Single-shot `select!` /// with exactly two exits (the accept, or the control stream's /// EOF/garbage/error); the accept branch's error maps to reply#2's -/// code — no data plane; the accept success writes reply#2 with the -/// peer address then `pump_bidi`s inline. +/// code, with no data plane; the accept success writes reply#2 with +/// the peer address then `pump_bidi`s inline. async fn bind_quiet_wait_and_serve(mut inner: T, accept: BindAccept) -> Result<(), SocksError> where T: AsyncRead + AsyncWrite + Unpin, @@ -552,8 +550,8 @@ where Ok(h) => h, Err(err) => { // Accept-stage failure (post-reply#1): reply#2 - // carries the mapped code; the session ends — no - // data plane. + // carries the mapped code; the session ends, so no + // data plane exists. let _ = inner .write_all(&new_reply(&err.to_reply_error(), sentinel_bind_peer_addr())) .await; @@ -564,13 +562,13 @@ where } }, read = inner.read(&mut chunk) => match read { - // Client gone: the accept future drops here — whatever + // Client gone: the accept future drops here; whatever // the provider opened (the listener, the tunnel) is // owned by that future, so it tears down with the // session (the client-gone contract, ADR-004/ADR-007). Ok(0) => return Ok(()), // Any byte in the quiet wait is protocol garbage (a BIND - // client never sends another control byte) — the session + // client never sends another control byte); the session // ends with no data plane, no reply#2. Ok(_) => { return Err(SocksError::ServerError( @@ -607,8 +605,8 @@ fn sentinel_bind_peer_addr() -> SocketAddr { /// dispatch registry: spec + establisher + pump + injected seams, /// via `ChannelCore::register_openable_with_establisher` (`timeout: /// None` = the 10s default bound, ADR-049 §2). Scope-gated -/// `socks5:open` (the registry runs the ACL before the establisher — -/// ADR-006). Missing callbacks = those commands refuse +/// `socks5:open` (the registry runs the ACL before the establisher, +/// per ADR-006). Missing callbacks = those commands refuse /// RFC-faithfully (`CommandNotSupported`-class), never a panic /// (ADR-008). pub fn register_socks5_openable( @@ -631,17 +629,17 @@ pub fn register_socks5_openable( } /// The producer assembly shape (producer.md §Wiring): the -/// registration's injected seams as a builder — the assembly layer +/// registration's injected seams as a builder; the assembly layer /// closes over substrate code behind the callbacks; the protocol /// layer never sees a socket type (ADR-004/ADR-008). Missing /// callbacks = those commands refuse RFC-faithfully (ADR-008). /// /// The BIND command gate on the substrate-free entry /// ([`Socks5Producer::serve_socks5_over`]) is the builder's -/// `bind_policy` — explicit via `.bind_policy(bool)`, defaulting to +/// `bind_policy`, explicit via `.bind_policy(bool)`, defaulting to /// the accept seam's presence for backward-compat. The channels /// path's gate is the pump handler's, resolved from the per-call -/// opener identity (ADR-006) — this policy never touches it. +/// opener identity (ADR-006); this policy never touches it. #[derive(Clone, Default)] pub struct Socks5Producer { dial: Option, @@ -657,12 +655,12 @@ impl Socks5Producer { /// The substrate-free protocol entry (the same shape /// [`serve_socks5_over`] pins) over the builder's seams. The - /// command gate is the builder's `bind_policy` — an explicit + /// command gate is the builder's `bind_policy`, an explicit /// `.bind_policy(bool)` whenever the local policy must speak /// ("factory wired but gate closed" is expressible here); /// unwired, it defaults to the accept seam's presence for /// backward-compat. The per-caller gate is the pump handler's, - /// resolved from the open-op identity — ADR-006. + /// resolved from the open-op identity, per ADR-006. pub async fn serve_socks5_over(&self, stream: T) -> Result<(), SocksError> where T: AsyncRead + AsyncWrite + Unpin, @@ -677,7 +675,7 @@ impl Socks5Producer { .await } - /// Register the SOCKS5 open op — the same shape + /// Register the SOCKS5 open op, the same shape /// [`register_socks5_openable`] pins, over the builder's seams + /// the optional identity witness. pub fn register( @@ -728,11 +726,11 @@ impl Socks5ProducerBuilder { } /// The substrate-free entry's BIND command gate (ADR-006's - /// posture — "listener configured" ≠ "listener permitted"): the + /// posture, "listener configured" ≠ "listener permitted"): the /// resolved `can_bind` for [`Socks5Producer::serve_socks5_over`]. /// Set it when the local policy must close the gate while the /// accept seam stays wired. Unwired, the gate defaults to the - /// accept seam's presence (the backward-compat reading — the + /// accept seam's presence (the backward-compat reading; the /// pump handler's identity-derived gate is unaffected). pub fn bind_policy(mut self, can_bind: bool) -> Self { self.bind_policy = Some(can_bind); @@ -765,7 +763,7 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream}; /// The test dial closure (the common module's witnessed echo - /// shape — the target's identity is returned to the test through + /// shape; the target's identity is returned to the test through /// the shared witness so the interception-point shape is pinned). fn echo_dial(targets: Arc>>) -> DialFn { echo_dial_witnessed(targets) @@ -835,7 +833,7 @@ mod tests { assert_eq!(&buf, b"ping"); // The dial seam saw the parsed RFC target (the interception - // point — domain form passes through unresolved, ADR-012). + // point; domain form passes through unresolved, ADR-012). assert_eq!( targets.lock().await.last(), Some(&TargetAddr::Domain("echo.test".into(), 443)) @@ -851,8 +849,8 @@ mod tests { } /// A dial refusal surfaces as the mapped RFC reply code in the - /// reply — no phantom data plane (nothing is dialed, no bytes - /// flow after the refusal). + /// reply, so no phantom data plane exists (nothing is dialed, no + /// bytes flow after the refusal). #[tokio::test] async fn dial_refusal_maps_to_reply_code_with_no_data_plane() { let dial = refusing_dial(SocksDialError::Refused("target down".into())); @@ -911,7 +909,7 @@ mod tests { } /// A missing dial callback refuses CONNECT RFC-faithfully - /// (`CommandNotSupported` — ADR-008's posture: an unprovided + /// (`CommandNotSupported`, per ADR-008's posture: an unprovided /// capability, never a panic). #[tokio::test] async fn missing_dial_refuses_connect_command_not_supported() { @@ -942,7 +940,7 @@ mod tests { // -- UDP ASSOCIATE: the datagram stage (ADR-003, POC #2 shape, // in-process over duplexes) ------------------------------------ // (the queue egress + its echo factory + the frame helpers ride - // the common module — the harness's single-sourced shapes.) + // the common module, the harness's single-sourced shapes.) use crate::egress::EgressError; @@ -984,7 +982,7 @@ mod tests { } /// The echo egress over the queue shape (the common module's - /// `echo_egress_factory` — the harness's `echo_egress`, same + /// `echo_egress_factory`, i.e. the harness's `echo_egress`, same /// shape): datagrams sent echo back as inner bytes. fn echo_egress_factory() -> EgressFactory { common::echo_egress_factory() @@ -1035,7 +1033,7 @@ mod tests { result.expect("clean session"); } - /// An empty-payload datagram is a legal datagram — never EOF (the + /// An empty-payload datagram is a legal datagram, never EOF (the /// F-2 invariant at payload level, end to end): it round-trips /// through the echo egress and the association survives it. #[tokio::test] @@ -1089,7 +1087,7 @@ mod tests { result.expect("clean session"); } - /// A undecodable frame (garbage ATYP — any post-reply control + /// A undecodable frame (garbage ATYP: any post-reply control /// byte parses as one) fails the association loud: EOF on the /// next read, no silent drop. #[tokio::test] @@ -1110,7 +1108,7 @@ mod tests { match outcome { Err(_) => panic!("the association must end after an undecodable frame"), Ok(Ok(0)) | Ok(Err(_)) => {} - Ok(Ok(n)) => panic!("EOF expected after an undecodable frame — got {n} bytes"), + Ok(Ok(n)) => panic!("EOF expected after an undecodable frame, got {n} bytes"), } drop(client); tokio::time::timeout(std::time::Duration::from_secs(5), handle) @@ -1163,7 +1161,7 @@ mod tests { /// The readiness contract (the POC's load-bearing API finding, /// re-pinned by this suite per ADR-003): an egress whose /// `next_reply` is an instantly-ready `Ok(None)` must not starve - /// the stage's channel branch — datagrams still egress and echo. + /// the stage's channel branch, so datagrams still egress and echo. /// (The stage tolerates spurious `Ok(None)`s by re-parking.) #[tokio::test] async fn udp_instant_ok_none_egress_does_not_starve_the_stage() { @@ -1193,8 +1191,8 @@ mod tests { &mut self, ) -> futures::future::BoxFuture<'_, Result, EgressError>> { Box::pin(async move { - // Drain one queued reply if any, then — instead of - // parking — return an instantly-ready `Ok(None)` + // Drain one queued reply if any, then (instead of + // parking) return an instantly-ready `Ok(None)` // (the contract violation the stage must tolerate; // an actual biased-select livelock would hang this // test's channel branch). @@ -1230,7 +1228,7 @@ mod tests { client.read_exact(&mut buf).await }) .await - .expect("the channel branch is not starved — the echo arrived") + .expect("the channel branch is not starved (the echo arrived)") .expect("read the echo"); let (_, echoed) = decode_datagram(&buf); assert_eq!(&echoed[..], b"hot"); @@ -1245,8 +1243,8 @@ mod tests { /// The readiness contract's zero-yield bound (the gate review's /// R-1, tasks/implementation/review-protocol-core.md): an - /// egress whose `next_reply` is a pure `async { Ok(None) }` — - /// instantly ready AND yielding no poll boundary — must still not + /// egress whose `next_reply` is a pure `async { Ok(None) }` + /// (instantly ready AND yielding no poll boundary) must still not /// starve the stage's channel branch. The stage's `Ok(None)` /// handler yields before re-parking; this test hangs (livelock) /// without that yield, on any scheduler. @@ -1279,7 +1277,7 @@ mod tests { ) -> futures::future::BoxFuture<'_, Result, EgressError>> { Box::pin(async { // Zero awaits: instantly ready, no yield point at - // all — the strictest readiness-contract + // all, the strictest readiness-contract // violation (the mutex-guarded hot egress in // `udp_instant_ok_none_egress_does_not_starve_ // the_stage` accidentally yields at its lock; @@ -1328,7 +1326,7 @@ mod tests { client.read_exact(&mut buf).await }) .await - .expect("the channel branch is not starved — the echo arrived") + .expect("the channel branch is not starved (the echo arrived)") .expect("read the echo"); let (_, echoed) = decode_datagram(&buf); assert_eq!(&echoed[..], b"pure"); @@ -1342,7 +1340,7 @@ mod tests { } /// A missing egress callback refuses ASSOCIATE RFC-faithfully - /// (`CommandNotSupported` 0x07 — ADR-008's missing-callback class, + /// (`CommandNotSupported` 0x07, ADR-008's missing-callback class, /// never a panic), and CONNECT still serves the same registration /// (the regression guard). #[tokio::test] @@ -1387,7 +1385,7 @@ mod tests { } /// The egress-factory failure (post-command, pre-reply) maps to - /// reply#1's code — no phantom datagram plane. + /// reply#1's code, with no phantom datagram plane. #[tokio::test] async fn egress_factory_failure_maps_to_reply_code() { let factory: EgressFactory = @@ -1425,7 +1423,7 @@ mod tests { /// The egress lifecycle: the factory is called once per /// ASSOCIATE, the stage owns the egress, and stage exit drops it - /// (the teardown-at-birth witness — the drop flag flips when the + /// (the teardown-at-birth witness; the drop flag flips when the /// channel EOFs, not before). #[tokio::test] async fn egress_factory_called_once_and_dropped_with_the_stage() { @@ -1594,7 +1592,7 @@ mod tests { } } - /// An egress whose relay already ended (`next_reply` errs) — the + /// An egress whose relay already ended (`next_reply` errs), the /// `EgressEnded`-via-`next_reply` trigger. struct RelayEndedEgress; @@ -1615,7 +1613,7 @@ mod tests { } /// An egress that parks forever on `next_reply` (the read branch's - /// exits are then deterministic — the parked-accept shape's egress + /// exits are then deterministic, the parked-accept shape's egress /// mirror). struct ParkedEgress; @@ -1636,9 +1634,9 @@ mod tests { } /// The `ReplyFrameRejected` exit: an egress reply whose inner - /// bytes violate the u16 joint bound — the wire codec refuses it - /// at `frame_inner`, the stage tears the association down (never a - /// wire overflow). + /// bytes violate the u16 joint bound; the wire codec refuses it + /// at `frame_inner`, and the stage tears the association down + /// (never a wire overflow). #[tokio::test] async fn datagram_stage_reply_frame_rejected_exit() { let mut egress = Box::new(OneReplyEgress { @@ -1655,7 +1653,7 @@ mod tests { assert_eq!(exit, StageExit::ReplyFrameRejected); } - /// The `EgressEnded` exit via `next_reply`'s `Err` — the egress + /// The `EgressEnded` exit via `next_reply`'s `Err`: the egress /// relay ended; the association ends with it (the lossy-datagram /// posture). #[tokio::test] @@ -1673,7 +1671,7 @@ mod tests { } /// The `Io` exit via a failing write: a valid reply frame arrives - /// but the channel's write half fails — the stage tears the + /// but the channel's write half fails, so the stage tears the /// association down. #[tokio::test] async fn datagram_stage_write_failure_is_the_io_exit() { @@ -1697,7 +1695,7 @@ mod tests { assert_eq!(exit, StageExit::Io); } - /// The `Io` exit via a failing channel read — the stage tears the + /// The `Io` exit via a failing channel read; the stage tears the /// association down (a stream error is never a silent end). #[tokio::test] async fn datagram_stage_read_failure_is_the_io_exit() { @@ -1717,7 +1715,7 @@ mod tests { /// serve layer (the drop-witness treatment /// `egress_factory_called_once_and_dropped_with_the_stage` gives /// the happy path): one datagram in, the egress refuses it, the - /// stage exits — EOF reaches the client and the egress drops with + /// stage exits; EOF reaches the client and the egress drops with /// the stage (teardown-at-birth on the failure path). #[tokio::test] async fn udp_egress_send_failure_ends_the_association_and_drops_the_egress() { @@ -1793,7 +1791,7 @@ mod tests { .expect("read resolves"); match outcome { Ok(0) | Err(_) => {} - Ok(n) => panic!("EOF expected after the egress send failure — got {n} bytes"), + Ok(n) => panic!("EOF expected after the egress send failure, got {n} bytes"), } drop(client); tokio::time::timeout(std::time::Duration::from_secs(5), handle) @@ -1845,7 +1843,7 @@ mod tests { .expect("join") .expect("clean"); - // ASSOCIATE second — the same seams. + // ASSOCIATE second, with the same seams. let (handle, mut client) = raw_associate(Some(echo_egress_factory())).await; let target = TargetAddr::Ip(SocketAddr::from(([10, 0, 0, 5], 53))); client @@ -1877,8 +1875,8 @@ mod tests { } /// The dispatch skeleton + the two-pump shape coexist: CONNECT - /// serves, ASSOCIATE refuses, BIND is gated — one - /// `serve_socks5_over`, all three commands (the regression + /// serves, ASSOCIATE refuses, and BIND is gated, all three + /// commands inside one `serve_socks5_over` (the regression /// guard). #[tokio::test] async fn dispatch_guard_connect_serves_while_associate_bind_refuse() { @@ -1945,7 +1943,7 @@ mod tests { } /// An accept factory whose future parks forever (the quiet wait's - /// other exits are then deterministic — garbage/EOF win, the + /// other exits are then deterministic (garbage/EOF win, the /// accept never does): the common module's shape. fn parked_accept_factory() -> BindAcceptFactory { common::parked_accept_factory() @@ -1980,7 +1978,7 @@ mod tests { "reply#1 carries the factory's bind_addr" ); // The factory saw the requested target (the interception - // point — the CONNECT dial's mirror). + // point, the CONNECT dial's mirror). assert_eq!( requested.lock().await.clone(), Some(TargetAddr::Domain("bind.test".into(), 2121)) @@ -1999,7 +1997,7 @@ mod tests { ); // Phase 2: raw pass-through, both directions (the CONNECT - // shape — zero per-packet framing). + // shape, zero per-packet framing). client.write_all(b"ping").await.unwrap(); let mut buf = [0u8; 4]; client.read_exact(&mut buf).await.unwrap(); @@ -2019,12 +2017,12 @@ mod tests { /// The command gate (ADR-006; POC #5 test 4): BIND without /// `socks5:bind` gets the RFC-faithful `ConnectionNotAllowed` - /// (0x02) — while CONNECT still serves the same registration + /// (0x02), while CONNECT still serves the same registration /// (the gate distinguishes ingress from egress; the scopes are /// never implied). #[tokio::test] async fn bind_command_gate_refuses_0x02_while_connect_still_serves() { - // The gate closed: 0x02 on BIND — no factory call (the gate + // The gate closed: 0x02 on BIND, hence no factory call (the gate // precedes the seam; a scope-less BIND must never mint a // listener). let requested = Arc::new(tokio::sync::Mutex::new(None)); @@ -2041,7 +2039,7 @@ mod tests { assert_eq!(code, 0x02, "the gate refuses ConnectionNotAllowed"); assert!( requested.lock().await.is_none(), - "the gate precedes the accept seam — no phantom listener" + "the gate precedes the accept seam, so no phantom listener" ); drop(client); let err = tokio::time::timeout(std::time::Duration::from_secs(5), handle) @@ -2074,7 +2072,7 @@ mod tests { } /// The quiet wait: a control byte after reply#1 is protocol - /// garbage — the session ends with no data plane (no reply#2, + /// garbage, so the session ends with no data plane (no reply#2, /// no bytes echo). #[tokio::test] async fn bind_quiet_wait_garbage_byte_ends_the_session() { @@ -2102,7 +2100,7 @@ mod tests { Err(_) => panic!("the session must end on quiet-wait garbage"), Ok(Err(_)) => {} Ok(Ok(0)) => {} - Ok(Ok(n)) => panic!("no reply#2 after garbage — got {n} bytes"), + Ok(Ok(n)) => panic!("no reply#2 after garbage, got {n} bytes"), } drop(client); let err = tokio::time::timeout(std::time::Duration::from_secs(5), handle) @@ -2117,7 +2115,7 @@ mod tests { } /// Client-gone teardown: EOF mid-quiet-wait drops the accept - /// future (the client-gone contract — whatever the provider + /// future (the client-gone contract; whatever the provider /// opened dies with it). The session ends cleanly with no reply#2. #[tokio::test] async fn bind_client_gone_teardown_drops_the_accept_future() { @@ -2143,7 +2141,7 @@ mod tests { async move { // Park forever (the wait's other branch // wins); the owner's drop fires on future - // teardown — the client-gone contract. + // teardown, the client-gone contract. std::future::pending::<()>().await; drop(owner); unreachable!("pending never resolves") @@ -2179,7 +2177,7 @@ mod tests { ); } - /// Factory failure (pre-reply#1) maps to reply#1's code — no + /// Factory failure (pre-reply#1) maps to reply#1's code, with no /// phantom listener (the factory never returned one). #[tokio::test] async fn bind_factory_failure_maps_to_reply1() { @@ -2208,8 +2206,8 @@ mod tests { )); } - /// Accept-stage failure (post-reply#1) maps to reply#2's code — - /// no data plane. + /// Accept-stage failure (post-reply#1) maps to reply#2's code, + /// with no data plane. #[tokio::test] async fn bind_accept_stage_failure_maps_to_reply2() { let factory: BindAcceptFactory = Arc::new(move |_target: TargetAddr| { @@ -2229,7 +2227,7 @@ mod tests { ) .await; let (code1, _addr) = read_reply(&mut client).await; - assert_eq!(code1, 0x00, "reply#1 succeeded — the factory resolved"); + assert_eq!(code1, 0x00, "reply#1 succeeded; the factory resolved"); let (code2, _addr) = read_reply(&mut client).await; assert_eq!( code2, 0x02, @@ -2249,9 +2247,9 @@ mod tests { /// The no-factory default (ADR-007 variant 3, wrapper-aware): the /// sentinel reply#1 + the sentinel reply#2 (`0.0.0.0:0`), the - /// full phase model preserved end to end (quiet wait included — + /// full phase model preserved end to end (quiet wait included, so /// garbage still ends the session), then raw pass-through (the - /// in-band halves are empty/sink — bytes flow one way to the + /// in-band halves are empty/sink; bytes flow one way to the /// sink, EOF comes back). #[tokio::test] async fn bind_no_factory_serves_the_wrapper_aware_variant_3() { @@ -2297,7 +2295,8 @@ mod tests { /// The establishment bound covers only the open-op round trip /// (ADR-007): the quiet wait outlives any establishment-length - /// timeout — a client that BINDs and stays silent (no dial) keeps + /// timeout, and a client that BINDs and stays silent (no dial) + /// keeps /// the session alive (the wait is teardown-governed; the /// over-channels shape is the harness's pin). #[tokio::test] @@ -2314,11 +2313,11 @@ mod tests { let (code, _addr) = read_reply(&mut client).await; assert_eq!(code, 0x00, "reply#1 succeeded"); - // Stay silent — the wait is teardown-governed, not + // Stay silent; the wait is teardown-governed, not // establishment-governed. (The substrate-free shape has no // alkcall bound; the harness integration test pins the // over-channels shape. Here the parked accept still answers - // after a sleep — the wait is alive, not under any in-stream + // after a sleep; the wait is alive, not under any in-stream // timeout.) tokio::time::sleep(std::time::Duration::from_millis(500)).await; client.write_all(b"x").await.unwrap(); @@ -2328,7 +2327,7 @@ mod tests { match outcome { Err(_) => panic!("the session must end on quiet-wait garbage"), Ok(Ok(0)) | Ok(Err(_)) => {} - Ok(Ok(n)) => panic!("no reply#2 after garbage — got {n} bytes"), + Ok(Ok(n)) => panic!("no reply#2 after garbage, got {n} bytes"), } drop(client); let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle) @@ -2339,8 +2338,8 @@ mod tests { /// The quiet wait's `Err(_)` arm (review 002 N-8): a channel read /// that *errors* (I/O error, not EOF) during the quiet wait ends - /// the wait with a clean `Ok(())` — the accept future drops with - /// the session (the client-gone contract's error-shaped twin). + /// the wait with a clean `Ok(())`, and the accept future drops + /// with the session (the client-gone contract's error-shaped twin). /// Driven directly on `bind_quiet_wait_and_serve` (a read error is /// not producible over a duplex). #[tokio::test] @@ -2358,7 +2357,7 @@ mod tests { cx: &mut Context<'_>, _buf: &mut tokio::io::ReadBuf<'_>, ) -> Poll> { - // Park once (both branches get polled — the accept + // Park once (both branches get polled; the accept // future starts, its owned state exists), then error // on the wake: the read branch's error resolves after // the accept branch has started. @@ -2425,7 +2424,7 @@ mod tests { /// The BIND reply#2 Domain-peer fallback (review 002 N-8): a /// foreign accept handle carrying a `TargetAddr::Domain(_, port)` - /// peer — the producer writes reply#2 with the unspecified + /// peer, so the producer writes reply#2 with the unspecified /// address on the peer's port (`0.0.0.0:port`) and serves the /// data plane. #[tokio::test] @@ -2493,7 +2492,7 @@ mod tests { ); // The data plane is live (the accept resolved with in-process - // halves — the parked probe half stays alive by forget). + // halves; the parked probe half stays alive by forget). client.write_all(b"ping").await.unwrap(); let mut buf = [0u8; 4]; tokio::time::timeout( @@ -2514,8 +2513,8 @@ mod tests { } /// The pump handler's shape: `accept_bi` → the same conversation, - /// with the handler's `JoinHandle` tracking the data plane (R-02 - /// — the spawn shape is inline-await, not spawn-and-forget). + /// with the handler's `JoinHandle` tracking the data plane (R-02: + /// the spawn shape is inline-await, not spawn-and-forget). #[tokio::test] async fn pump_handler_tracks_the_data_plane_to_completion() { let targets = Arc::new(tokio::sync::Mutex::new(Vec::new())); @@ -2524,7 +2523,7 @@ mod tests { let handler = make_socks5_pump_handler(Some(Arc::clone(&dial)), None, None); // A fake alkcall `Connection` is not constructible outside - // alkcall; drive the substrate-free path instead — the + // alkcall; drive the substrate-free path instead; the // handler's task body is exactly `accept_bi` + // `serve_socks5_over` (the shape this test pins is the // inline-await). The full handler-over-channels shape is the @@ -2549,7 +2548,7 @@ mod tests { drop(client); let result = tokio::time::timeout(std::time::Duration::from_secs(5), serve) .await - .expect("the session ends on client EOF — the data plane is tracked") + .expect("the session ends on client EOF, so the data plane is tracked") .expect("join"); result.expect("clean session"); assert_eq!( @@ -2559,7 +2558,7 @@ mod tests { } /// The establisher records the opener identity (CF-006) and - /// returns the no-plan establishment (the session gate — the + /// returns the no-plan establishment (the session gate; the /// target is unknown at open time, POC #1's wire note). #[tokio::test] async fn establisher_records_identity_and_defaults_the_establishment() { @@ -2595,7 +2594,7 @@ mod tests { /// The command gate's resolved boolean rides the open-op /// `AuthContext`'s identity scopes (CF-005/CF-006, ADR-006): the - /// gate is `socks5:bind`-scoped — never implied by `socks5:open`. + /// gate is `socks5:bind`-scoped and never implied by `socks5:open`. #[test] fn pump_handler_resolves_the_bind_gate_from_the_opener_identity() { fn gate(scopes: &[&str]) -> bool { @@ -2652,11 +2651,12 @@ mod tests { } /// The gate policy (review 002 C-3): the builder's `bind_policy` - /// is the substrate-free entry's BIND gate — "factory wired but + /// is the substrate-free entry's BIND gate: "factory wired but /// gate closed" is expressible (`bind_policy(false)` with the - /// accept seam still wired refuses BIND 0x02, no factory call — - /// the gate precedes the seam) while CONNECT and ASSOCIATE still - /// serve the same producer. Seam presence is never the sole gate. + /// accept seam still wired refuses BIND 0x02 and calls no + /// factory; the gate precedes the seam) while CONNECT and + /// ASSOCIATE still serve the same producer. Seam presence is + /// never the sole gate. #[tokio::test] async fn builder_bind_policy_false_refuses_bind_while_connect_associate_serve() { let requested = Arc::new(tokio::sync::Mutex::new(None)); @@ -2668,8 +2668,8 @@ mod tests { .build(); // BIND with the factory wired and the gate closed: 0x02, and - // the accept seam is never called (no phantom listener — the - // gate precedes the seam, the ADR-006 shape). + // the accept seam is never called (no phantom listener; the + // gate precedes the seam, per the ADR-006 shape). let (client_end, server_end) = tokio::io::duplex(64 * 1024); let serve = tokio::spawn(async move { producer.serve_socks5_over(server_end).await }); let mut client = client_end; @@ -2687,7 +2687,7 @@ mod tests { ); assert!( requested.lock().await.is_none(), - "the closed gate precedes the accept seam — no phantom listener" + "the closed gate precedes the accept seam; no phantom listener" ); drop(client); let err = tokio::time::timeout(std::time::Duration::from_secs(5), serve) @@ -2773,7 +2773,7 @@ mod tests { /// The backward-compat default (review 002 C-3): an unwired /// `bind_policy` resolves the gate from the accept seam's - /// presence — a factory-wired producer serves BIND on the + /// presence, so a factory-wired producer serves BIND on the /// substrate-free entry (the landed tests' reading preserved), /// while a producer without the factory still refuses. The /// explicit policy always wins when wired. @@ -2809,7 +2809,7 @@ mod tests { result.expect("clean"); // Accept unwired, policy unwired → BIND refuses (the gate's - // no-grant answer — no seam to run even if the gate were + // no-grant answer; no seam to run even if the gate were // open). let producer = Socks5Producer::builder() .dial(echo_dial(Arc::new(tokio::sync::Mutex::new(Vec::new())))) @@ -2839,7 +2839,7 @@ mod tests { } /// The sentinel reply address (the in-band BND.ADDR posture, - /// ADR-003): `127.0.0.1:0` — the RFC-compatibility surface. + /// ADR-003): `127.0.0.1:0`, the RFC-compatibility surface. #[test] fn sentinel_reply_addr_shape() { assert_eq!(sentinel_reply_addr(), SocketAddr::from(([127, 0, 0, 1], 0))); @@ -2847,7 +2847,7 @@ mod tests { /// The pump handler's seam-closure shape: `Clone`-carried /// `Option` seams survive the closure move (the registration's - /// shape — the handler is reusable across sessions). + /// shape; the handler is reusable across sessions). #[test] fn producer_builder_seams_survive_clone() { let producer = Socks5Producer::builder() diff --git a/src/rfc1928/LICENSE b/src/rfc1928/LICENSE index b388591..911ba49 100644 --- a/src/rfc1928/LICENSE +++ b/src/rfc1928/LICENSE @@ -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, docs/architecture/decisions/011-inline-vendor.md; extraction range 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 per-file license headers; this bundled notice is the license 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 (MIT OR Apache-2.0, per alksocks' Cargo.toml); the upstream MIT 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. --- diff --git a/src/rfc1928/client.rs b/src/rfc1928/client.rs index 72ed1bc..b15c92f 100644 --- a/src/rfc1928/client.rs +++ b/src/rfc1928/client.rs @@ -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`). //! //! Extracted region: the `T`-generic -//! client session — [`Socks5Stream`] with the `use_stream` path +//! client session: [`Socks5Stream`] with the `use_stream` path //! (the version/method greeting, the method-choice read, the //! password-auth sub-handshake, the request encoder `request_header`, //! 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 //! fed here directly). A `Socks5Stream` is itself a stream, so //! `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): //! `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 //! swap) and the `Socks5Stream` 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 //! channels `BiStream`, not a TCP dial). Upstream //! `Config::connect_timeout` is cut with its only consumer, @@ -26,13 +26,13 @@ //! //! Extraction-time adjustments (mechanical, ADR-011 §Amendment): //! `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 //! [`SocksError::MissingTargetAddr`] variant instead of //! `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 -//! ([`SocksError::AuthenticationRejected`]) — convention 2 forbids +//! ([`SocksError::AuthenticationRejected`]); convention 2 forbids //! panics in library code, and the state is reachable only by misuse; //! the `Socks5Datagram` error sites (`SocksError::Other(anyhow!(...))`, //! 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 //! 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. use crate::rfc1928::util::stream::read_exact; @@ -208,7 +208,7 @@ where ref username, ref password, }) => (username, password), - // upstream `unreachable!()` — `methods.get(1)` being + // upstream `unreachable!()` (`methods.get(1)` being // `Some(AuthenticationMethod::None)`: reachable only by // misuse; the faithful translation is an error return, // not a panic (convention 2). Same message as upstream's diff --git a/src/rfc1928/mod.rs b/src/rfc1928/mod.rs index 4e9dc40..7630d32 100644 --- a/src/rfc1928/mod.rs +++ b/src/rfc1928/mod.rs @@ -1,12 +1,12 @@ //! 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 //! read-only reference checkout, kept for differential testing of //! RFC edge cases). //! //! Arrived by extraction (ADR-011 §Amendment, docs/architecture/ //! 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`/ //! `Authentication` layer were **not** copied. The extraction cut, //! the fork's two carried changes (BIND support, `new_reply` @@ -15,7 +15,7 @@ //! `docs/architecture/rfc1928.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, //! no lint-allow posture). Fork point: the tree as of the //! extraction-review commit (`5b18d4d`); extraction provenance in @@ -32,13 +32,13 @@ //! `use_stream` path + the `AsyncRead`/`AsyncWrite` impls) lives in //! [`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 + //! the fork-point statement) is the license obligation's carrier. //! //! Maintenance posture: this code co-evolves with the wrapper and is //! 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. pub mod client; @@ -216,7 +216,7 @@ impl ReplyError { /// Map a wire reply code to its variant. Codes outside the RFC /// 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 /// reply byte here), so a misbehaving peer must surface as a typed /// 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 - /// §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 /// comes off the wire (`read_request_reply` → `from_u8`), so a /// misbehaving peer must not crash the consumer task. diff --git a/src/rfc1928/server.rs b/src/rfc1928/server.rs index a37d5ef..e8ce959 100644 --- a/src/rfc1928/server.rs +++ b/src/rfc1928/server.rs @@ -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`). //! //! Extracted region: the explicit @@ -6,13 +6,13 @@ //! negotiation with the `AuthMethod` machinery, `read_command`, //! `reply_success`, `reply_error`, `transfer`, `wait_on_tcp`, //! `ErrorContext`), `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 //! BIND reply#2 writer and consumer reply readers reuse the //! 10/22-byte reply form instead of re-encoding it). The cut regions //! (the legacy `Socks5Server`/`Socks5Socket`/`Config`/`Authentication` //! 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`. //! //! Extraction-time adjustments (mechanical, ADR-011 §Amendment): @@ -23,22 +23,22 @@ //! `SocksServerError::AuthenticationRejected` are cut; the variant //! 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 -//! machinery — the `auth_method_enums!` macro (upstream's enum +//! machinery: the `auth_method_enums!` macro (upstream's enum //! generator) with its generated `StandardAuthentication` enum + //! `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` //! directly. Kept with note (the hand-port map, ADR-013): //! `transfer`/`wait_on_tcp` are differential-suite-consumed (their //! semantics stay pinned there), and the error vocabulary keeps its //! 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 //! 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()`, //! `reply_success(sock_addr)` carries reply#1's BND.ADDR, and //! reply#2 is written with the published `new_reply`. No BIND code @@ -159,7 +159,7 @@ impl Socks5ServerProtocol { /// Closure-result adapter for [`Socks5ServerProtocol /// ::accept_password_auth`]'s credential checker: `bool` (`is_good`). /// 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 /// ever wants it. pub trait CheckResult { @@ -240,7 +240,7 @@ pub trait AuthMethodSuccessState { /// Use blank structs for individual protocol implementations; /// `negotiate_auth` takes any slice of `AuthMethod`s, so a custom /// 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). pub trait AuthMethod: Copy { type StartingState; @@ -354,7 +354,7 @@ impl PasswordAuthenticationImpl Socks5ServerProtocol; @@ -210,7 +211,7 @@ impl ToTargetAddr for &TargetAddr { pub enum Addr { V4([u8; 4]), 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). Domain(String), } @@ -218,7 +219,7 @@ pub enum Addr { /// This function is used by the client & the server. /// /// 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 /// widens that field (an FRAG-style extension) requires an explicit /// cap here before the allocation. diff --git a/src/wire.rs b/src/wire.rs index 16d84d5..ce080fa 100644 --- a/src/wire.rs +++ b/src/wire.rs @@ -1,5 +1,5 @@ //! 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 //! pass-through (no framing). Normative WHAT: `wire.md` §The Data //! Plane; the binary contract: `bast.md` (`AssociateFrame`). The @@ -7,18 +7,18 @@ //! `frame_datagram`/`DatagramReader` (same F-2 behavior; no //! 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 //! 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 -//! 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 //! writer adds the single length prefix, and the consumer's send path //! frames via [`frame`] exactly once. The double-framing bug class //! 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 //! prefix; nothing else adds one. //! @@ -26,7 +26,7 @@ //! field covers `addr + data`, not the payload alone: a frame is //! rejected ([`WireError::Oversize`], at frame time) whenever //! `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 //! payload per address form: 65276 (domain, 255 chars), 65516 (IPv6), //! 65528 (IPv4). [`addr_encoding_len`] exposes the per-address @@ -34,9 +34,9 @@ //! u16 arithmetic is used on the payload bound. //! //! **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 -//! `(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 //! `len` of 0 is protocol garbage and surfaces as //! [`WireError::EmptyFrame`].) @@ -44,11 +44,11 @@ //! **EOF layering.** EOF is the channels-level sentinel on the //! `BiStream`, outside this codec: the codec never emits zero-length //! reads, so the sentinel and the datagram stream never collide (an -//! empty datagram and a zero-byte read are different shapes — the -//! reason boundary-preserving framing is mandatory for the datagram +//! empty datagram and a zero-byte read are different shapes; that is +//! why boundary-preserving framing is mandatory for the datagram //! stage at all). A stream end mid-frame is the session's fail-loud //! 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. 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`): /// 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. pub const MAX_INNER_LEN: usize = u16::MAX as usize; @@ -69,12 +69,12 @@ const FRAME_LEN_FIELD: usize = 2; /// Codec errors. Two families: /// /// - **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 -/// 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): /// [`WireError::EmptyFrame`], [`WireError::BadAtyp`], -/// [`WireError::ShortAddress`], [`WireError::DomainNotUtf8`] — all +/// [`WireError::ShortAddress`], [`WireError::DomainNotUtf8`]: all /// protocol-garbage shapes a compliant peer never produces. A bad /// ATYP is its own variant so the producer stage can fail the /// association loud (`udp_undecodable_frame_ends_association`, @@ -87,24 +87,24 @@ const FRAME_LEN_FIELD: usize = 2; #[derive(Debug, Error)] pub enum WireError { /// 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)`. #[error("datagram frame too large for u16 length prefix: {0} bytes of addr + data (max {1})")] Oversize(usize, usize), /// The address could not be encoded (`TargetAddr::to_be_bytes` - /// refused — e.g. `AddrError::DomainLenTooLong`). + /// refused, e.g. `AddrError::DomainLenTooLong`). #[error("address encode: {0}")] 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 /// always ≥ 7 bytes); protocol garbage. #[error("empty frame: no address bytes (len == 0 is unreachable from a compliant peer)")] EmptyFrame, /// 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: /// `udp_undecodable_frame_ends_association`, POC #2. #[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 + -/// 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). /// Callers compute the joint bound with it: a payload is framable for /// `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 + /// data` is `TargetAddr::to_be_bytes()` (ATYP + addr + port) followed /// 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 /// 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. pub fn frame(addr: &TargetAddr, payload: &[u8]) -> Result { let addr_bytes = addr.to_be_bytes()?; @@ -158,7 +158,7 @@ pub fn frame(addr: &TargetAddr, payload: &[u8]) -> Result { 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 /// `[len: u16 BE][inner]` frame. The reply-direction half of the /// framing-once invariant: the egress speaks inner bytes (no length @@ -177,14 +177,14 @@ pub fn frame_inner(inner: &[u8]) -> Result { /// Incremental datagram decoder over the framed stage's read /// 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 /// 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 /// 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 /// addr decode (the garbage shapes). The reader keeps going after a /// 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 - /// 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 /// u16 declared length). Infallible reassembly; per-frame addr /// decode errors are the [`WireError`] decode variants. @@ -249,13 +249,13 @@ impl DatagramReader { } /// `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 /// truncated-datagram condition, never a silent partial /// datagram). /// - /// Kept public for the anticipated batch-2 consumer — a front-door - /// bridge driving its own reader loop (the doors) — so a later + /// Kept public for the anticipated batch-2 consumer (a front-door + /// bridge driving its own reader loop, the doors), so a later /// dead-surface sweep does not flag it (review-002 N-9). pub fn is_mid_frame(&self) -> bool { self.current.is_some() @@ -273,7 +273,7 @@ impl DatagramReader { /// Decode one frame's inner bytes (`addr + data`, the length prefix /// 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, /// mirrored synchronously (the stage's decode must not be async over /// 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() { // The reply path (frame_inner over pre-encoded inner bytes) // 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 payload = b"payload"; let mut inner = addr.to_be_bytes().unwrap().to_vec(); diff --git a/tasks/architecture/adr-014-idle-timeout-posture.md b/tasks/architecture/adr-014-idle-timeout-posture.md index 4d21d05..25a0acd 100644 --- a/tasks/architecture/adr-014-idle-timeout-posture.md +++ b/tasks/architecture/adr-014-idle-timeout-posture.md @@ -1,6 +1,6 @@ --- 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 depends_on: [] scope: narrow @@ -12,38 +12,38 @@ level: implementation ## Description 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, unbounded time). The alkcall 10s establishment bound governs the *open-op call*, not the in-stream conversation (`src/producer.rs:183-191` parks on `accept_bi` → `accept_no_auth` → `read_command` with no timeout; the datagram stage's loop exits 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 -channel" — implicit, never decided. +channel": implicit, never decided. The mitigations are real (the 256-channel cap bounds the count, 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 entries indefinitely *without sending a byte*. And batch 2's providers (`local`, `composed`) are about to route *vanilla clients* through these same phases via the front doors, where the 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. -**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 `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 control vocabulary" and association-lifetime-equals-channel); record the DoS posture explicitly (the cap bounds it; doors 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 `Socks5ProducerBuilder`), enforced by racing the handshake/stage 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 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 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 -(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 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 must do if the protocol layer does not) - [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 (the batch-2 tasks may depend on this one) - [x] `cargo doc --no-deps` clean if any doc-comment carries the @@ -86,28 +86,28 @@ one. ## References - 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 - existing decision — the contrast case) + existing decision, the contrast case) - docs/architecture/decisions/008-provider-defaults.md, 009-feature-gates.md (the batch-2 provider posture the decision precedes) - docs/architecture/wire.md §UDP ASSOCIATE (association lifetime = - channel lifetime — the contract the decision qualifies) -- The POC findings (the stage's measured shapes — the + channel lifetime; the contract the decision qualifies) +- The POC findings (the stage's measured shapes, the "sufficient for relay postures" lineage) ## Notes > 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 teardown-governed (EOF / garbage / error sentinels), never idle-expired. No code changed; the posture is recorded. - **The sharpening argument** (why a handshake-only bound is 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 what those holds cost (one parked task + one channel entry); a handshake-only bound protects a sliver while the dominant hold @@ -115,24 +115,24 @@ one. timeout, which contradicts the landed contracts (association lifetime = channel lifetime; "FTP-length waits stay legal," 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**: 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 forked table (`src/rfc1928/mod.rs`). Recorded in the ADR; relevant 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. - **The doors' obligations are recorded pre-batch-2** (the "inherits this bound knowingly" pattern, wire.md §UDP - ASSOCIATE's): an accept-stage idle bound (the slowloris sliver — - an accepted socket is not a channel, the cap regime does not bound + ASSOCIATE's): an accept-stage idle bound (the slowloris sliver: + an accepted socket is not a channel, the cap regime does not bound it), per-credential/per-peer session caps (the cred-bearing door's config table, ADR-005, is the knob), and accept-queue/ relay-task bounds. Same obligations for the producer-side local 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; `tokio::time` already in the wasm-clean subset; timeout = EOF- shaped teardown). Revisit trigger recorded: a deployment with a @@ -143,19 +143,19 @@ one. ADR-014 landed (`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 BIND quiet wait, and the association lifetime are all teardown-governed; the BIND relationship is recorded as *same 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 from a slow legitimate peer, so timeout policy belongs to the 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 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 "no in-band control vocabulary" bullet + the preamble's ADR list + frontmatter; producer.md §The Open Handler's pump description + the diff --git a/tasks/architecture/oq-sk2-06-wasm-adapter-story.md b/tasks/architecture/oq-sk2-06-wasm-adapter-story.md index 44ee494..4e69197 100644 --- a/tasks/architecture/oq-sk2-06-wasm-adapter-story.md +++ b/tasks/architecture/oq-sk2-06-wasm-adapter-story.md @@ -1,6 +1,6 @@ --- 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 depends_on: [] scope: single @@ -13,16 +13,16 @@ level: research External-trigger tracker for OQ-SK2-06 (`docs/architecture/ 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, deferred-oq]` per `docs/sdd_process.md` §Deferred OQs. **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. **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 check). If a product surface later names one, convert this tracker to an owned dependency in the same round. @@ -40,7 +40,7 @@ When the condition arrives: ## Verification -N/A (tracking task — trivial risk, no code). +N/A (tracking task; trivial risk, no code). ## Out of scope diff --git a/tasks/architecture/oq-sk2-07-virtual-subnet-helper.md b/tasks/architecture/oq-sk2-07-virtual-subnet-helper.md index 81133ea..218b358 100644 --- a/tasks/architecture/oq-sk2-07-virtual-subnet-helper.md +++ b/tasks/architecture/oq-sk2-07-virtual-subnet-helper.md @@ -1,6 +1,6 @@ --- 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 depends_on: [] scope: single @@ -13,13 +13,13 @@ level: research External-trigger tracker for OQ-SK2-07 (`docs/architecture/ 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, deferred-oq]` per `docs/sdd_process.md` §Deferred OQs. **Unblocking condition**: a concrete deployment wants identity-scoped -virtual subnets (proxied resources IP-addressable per identity) — -the shape ADR-012 resolved as assembly-layer policy over the dial +virtual subnets (proxied resources IP-addressable per identity), the +shape ADR-012 resolved as assembly-layer policy over the dial callback. ## Work @@ -36,7 +36,7 @@ When the condition arrives: ## Verification -N/A (tracking task — trivial risk, no code). +N/A (tracking task; trivial risk, no code). ## Out of scope diff --git a/tasks/implementation/assembly-integration-suite.md b/tasks/implementation/assembly-integration-suite.md index d352fe5..71d70ec 100644 --- a/tasks/implementation/assembly-integration-suite.md +++ b/tasks/implementation/assembly-integration-suite.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/door-udp, implementation/local-backend-producer, implementation/composed-accept] scope: moderate @@ -23,36 +23,36 @@ Deliverables: - **The full `-D` story end to end**: a vanilla tun2proxy-shaped client → the cred-bearing TCP door → the channels-native 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 door) all through one door process against one producer registration (the one-registration-three-commands regression, now through the doors). - **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 table, BIND through the bind-first listener (the ADR-008 composed variant set, all three commands). - **Mixed assembly**: one producer registration serving 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). - **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 immediately after each teardown (no connection poisoning). - **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 record the observed error in Notes). 2. **Docs sync** (the architecture-sync check): - `docs/architecture/README.md` Current State + the doc statuses reflect the landed assembly layer (the "no `local`/`composed` 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). - 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): `cargo test`, `cargo test --all-features`, clippy `--all-targets --all-features -- -D warnings`, `cargo fmt @@ -66,7 +66,7 @@ Deliverables: commands - [x] The composed variant set pinned end to end (all three commands) - [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 bounds, fresh-session-after-teardown) - [x] All four verification targets green (default, all-features, @@ -76,7 +76,7 @@ Deliverables: ## 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 §Producer-Side Local Backend, §Wiring - docs/architecture/decisions/005/007/008/009/014 @@ -91,24 +91,24 @@ Deliverables: - **The spec drift the implementation surfaced (one real drift, reconciled in `src/composed/mod.rs`):** `ComposedEgress::next_reply` 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` when no datagram has arrived) ended every quiescent association at birth. The composed-egress suite drove the egress send-first and 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 and every relay has ended; a never-opened table parks (the readiness contract's `Pending`, the channel branch governs). The landed spec 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` (`fresh_association_parks_before_any_tunnel_is_opened`). - **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 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 producer registration. The suite pins the assembly story as "one registration, many connections" (consumer.md reconciled). @@ -116,7 +116,7 @@ Deliverables: door shape (the cred-bearing TCP door's RFC half consumes the greeting + one command; the producer-side `ProducerDoor`'s `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 the suite's structure (each command opens its own door connection). @@ -125,12 +125,12 @@ Deliverables: test bind, the accept queue + loop, the channel-set probe, the identities) moved from `tests/composed-accept.rs` into `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 - (`composed_full_opener_identity` — `tunnel:open` + `tunnel:direct` + (`composed_full_opener_identity`: `tunnel:open` + `tunnel:direct` + `tunnel:forwarded` + `socks5:open` + `socks5:bind`) is new. - **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. If using Tokio, disable the net feature.` (mio v1.2.3, via `tokio/net` which `local` enables), followed by the mio `sys` @@ -150,14 +150,14 @@ Deliverables: > 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 end to end (feature `local` + `composed`): 1. **The full `-D` story** (`full_dash_d_story_...`): a vanilla tun2proxy-shaped client through the cred-bearing RFC 1929 TCP door + 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) + ASSOCIATE (the RFC-header'd datagram through the relay socket) 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). 2. **The composed story** (`composed_story_...`): the producer with `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 - `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 concurrent on one registration (the doors on their own 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). 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: `README.md` Current State (the assembly layer landed; both suites named), `lib.rs` crate docs (the assembly-layer paragraph + both 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 composed-accept machinery single-sourced into `tests/harness.rs` (the 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 warnings), both wasm checks green (default + `--features composed`, check + clippy), and the `local`-on-wasm build failure observed + -recorded (the alktty posture — mio's unsupported-wasm error). \ No newline at end of file +recorded (the alktty posture: mio's unsupported-wasm error). \ No newline at end of file diff --git a/tasks/implementation/composed-accept.md b/tasks/implementation/composed-accept.md index 7e59c11..9039dc9 100644 --- a/tasks/implementation/composed-accept.md +++ b/tasks/implementation/composed-accept.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/composed-egress] scope: moderate @@ -14,7 +14,7 @@ level: implementation The `composed` feature's accept provider (ADR-008's composed variants; ADR-007 variant 2): the BIND accept whose listener 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. **Vanilla-fidelity with no stand-ins since the graduation** (ADR-007): reply#1 advertises the real OS-chosen `bound` (the open-op reply's `bound` field, alkcall @@ -23,15 +23,15 @@ op's `peer` param). Deliverables: -1. **`src/composed/` additions** (feature `composed`) — the composed +1. **`src/composed/` additions** (feature `composed`): the composed accept, named per producer.md §Wiring: - `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 rides the accept future; the factory carries an expected `bind_addr`) is **retired** (ADR-007 §Consequences). The landed 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 future correlates the forwarded sessions (the `listen` token → the adopted forwarded sessions the assembly correlates; the @@ -45,7 +45,7 @@ Deliverables: ordinary adopted sessions on the manager; the listener does not own the receipt queue"). The provider owns: the `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 pump. Design the wait shape against the landed alkcall 0.8.0 adopt surface (`manager.adopt_channel`) and record it in Notes. @@ -53,8 +53,8 @@ Deliverables: accepted halves must be `Box` (`BindTargetHandle`'s payloads); alktunnels' `Taken Halves` yields `Send + Unpin` only. The owner-wrapper rides - **inside the handle payloads** (egress.rs's documented posture) - — the wrapper owns the adopted channel session so dropping the + **inside the handle payloads** (egress.rs's documented posture): + the wrapper owns the adopted channel session so dropping the handle drops everything (the teardown-at-birth discipline, compiler-enforced). This is the load-bearing wrapper: design it once (with composed-egress's needs), pin it with a test. @@ -64,15 +64,15 @@ Deliverables: variant's. - **The establishment bound covers only the open-op round trip** (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) closes with it. The forwarded-op correlation must survive a 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 vanilla-fidelity win, the correlation shape, the owner-wrapper. 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 producer with `composed::bindfirst_accept(...)`, a consumer BIND session: reply#1 carries the real `bound`, an app server dials @@ -89,14 +89,14 @@ Deliverables: - [ ] `cargo clippy --all-targets --all-features -- -D warnings` clean; `cargo fmt --check` clean - [ ] The graduation's shape lands (reply#1 = the real `bound`; - reply#2 = the forwarded op's real `peer`) — no expected-addr - stand-in — pinned by test + reply#2 = the forwarded op's real `peer`, no expected-addr + stand-in), pinned by test - [ ] 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 - 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 - isolation — pinned by tests + isolation, pinned by tests ## References @@ -106,10 +106,10 @@ Deliverables: - docs/architecture/producer.md §Wiring (`composed::bindfirst_accept`), §The Three Seams - 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) -- Landed surfaces: `src/egress.rs` (`BindAccept`/`BindTargetHandle` - — the contracts), alktunnels 0.2.0 (`register_tunnel_bindfirst_ +- Landed surfaces: `src/egress.rs` (`BindAccept`/`BindTargetHandle`, + the contracts), alktunnels 0.2.0 (`register_tunnel_bindfirst_ openable`, `BindFirstInjections`, `TunnelListener`, `register_tunnel_forwarded_openable`, `ForwardedHandler`) @@ -125,26 +125,27 @@ Deliverables: removes the entry on the arriving forwarded open and delivers `ForwardedSession { far duplex halves, peer }`. A dropped receiver (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). - **The Sync-bound wrinkle**: alktunnels' halves are `Send + Unpin` only; the landed `BindTargetHandle` payloads carry `+ Sync`. Two adapters pin the no-unsafe Sync proof (`Mutex` 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). - **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 → the far teardown cascades (teardown structural, compiler-enforced). The edition-2021 disjoint-capture trap (an 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. - **The three-party bridge pump**: the forwarded handler bridges the forwarded `BiStream` to a local duplex pair whose far end travels into the accept future's payloads. The bridge completes on EITHER 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 chain closes on itself). Dropping everything EOFs the SOCKS5 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 reap rides the per-adopter contract + transport EOF, NOT the 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 forwarded entries are adopted sessions owned by their adopter). `assert_only_channels` therefore treats odd producer-side ids as @@ -162,8 +163,8 @@ Deliverables: - **Forwarded-open failure semantics**: a FORBIDDEN forwarded open is per-accept isolated AT THE TUNNEL LAYER (the producer's accept loop drops that connection and keeps serving; the alktunnels - producer-suite pin). The SOCKS5 accept future stays parked — the - SOCKS5 producer never learns of the tunnel-layer failure — so no + producer-suite pin). The SOCKS5 accept future stays parked; the + SOCKS5 producer never learns of the tunnel-layer failure, so no reply#2 arrives; the quiet wait is unaffected. Pinned by test. ## 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 Sync-bound wrinkle is met by `SyncHalves`/`OwnedWrite` (mutex Sync-proof, no 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 `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 @@ -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 --all-features`, `cargo check/clippy --target wasm32-unknown-unknown` (hard gate), `cargo clippy --all-targets --all-features -- -D warnings`, -`cargo fmt --check` — all clean. \ No newline at end of file +`cargo fmt --check`: all clean. \ No newline at end of file diff --git a/tasks/implementation/composed-egress.md b/tasks/implementation/composed-egress.md index 9569ac9..cac04d7 100644 --- a/tasks/implementation/composed-egress.md +++ b/tasks/implementation/composed-egress.md @@ -1,6 +1,6 @@ --- 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 depends_on: [] scope: moderate @@ -14,88 +14,88 @@ level: implementation The `composed` feature's egress provider (ADR-008's composed variants): the ASSOCIATE egress that dials each destination through 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 destination→tunnel table LRU-evicted at a cap (POC #2's `ComposedEgress` shape). 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 - 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 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). -2. **`src/composed/mod.rs`** (feature `composed`) — the composed +2. **`src/composed/mod.rs`** (feature `composed`): the composed providers, named per producer.md §Wiring: - `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 alktunnels UDP-tunnel channel (`open_direct`, `Substrate::Udp`), a relay task per tunnel drains inbound datagrams into a shared queue, the write half stays in the table (LRU-capped; dropping 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 alktunnels' surface)**: the landed `TargetHandle`/`BindTarget Handle` halves are `Box`, but alktunnels' `TakenHalves` yields `Send + Unpin` only. The composed providers need an **owner-wrapper inside the handle 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 - session (the egress owns it — not a handle payload); for the - composed *accept* (the sibling task) the wrapper is load-bearing - — design it here, share it. + session (the egress owns it, not a handle payload); for the + composed *accept* (the sibling task) the wrapper is load-bearing, + so design it here and share it. - **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 - `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, - 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; framing-once). - **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 (wire.md's note: "the batch-2 composed-egress author inherits 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 - `SubstrateAddr` — the destination's `TargetAddr` renders to it - (IP forms direct; domain form passes through unresolved — + `SubstrateAddr`: the destination's `TargetAddr` renders to it + (IP forms direct; domain form passes through unresolved, producer-side resolution, the ssh `-D` semantic; ADR-012). 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). - Module doc: names the ADRs (008/009), the per-target-ACL benefit, the readiness contract, the framing-once invariant, the backpressure bound. 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 egress against it, datagram round-trips through the tunnel (multiple destinations → multiple tunnels; the LRU eviction at cap pinned by test); the readiness contract re-pinned (park-then- 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 - [x] `cargo test` passes; `cargo test --all-features` passes - [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` clean; `cargo fmt --check` clean - [x] `alktunnels` pinned `"0.2.0"`; the dep rides `composed` alone - (default graph unchanged — `cargo tree` spot-check) -- [x] The lazy table + LRU eviction land per the POC shape — pinned - by test + (default graph unchanged, per the `cargo tree` spot-check) +- [x] The lazy table + LRU eviction land per the POC shape (pinned + by test) - [x] The readiness contract honored (real async park; closed queue - → `Err`, never a hot `Ok(None)`) — pinned by test -- [x] Framing-once (replies are inner bytes; no double prefix) — + → `Err`, never a hot `Ok(None)`), pinned by test +- [x] Framing-once (replies are inner bytes; no double prefix), pinned by test - [x] Per-datagram send backpressure documented on the constructor @@ -107,7 +107,7 @@ Deliverables: - docs/architecture/producer.md §Wiring (`composed::direct_egress`), §The Three Seams - 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) - Landed surfaces: `src/egress.rs` (the seam contracts + the owner-wrapper posture), alktunnels 0.2.0 @@ -119,14 +119,14 @@ Deliverables: > Landed as `src/composed/mod.rs` (feature `composed`) + > `tests/composed-egress.rs`, with a `wire_tunnel_direct` topology > 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). > > Two deviations from the POC shape, both deliberate: > > - **The closed-queue condition is a live-relay count, not a queue > 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 > through this surface. Relays instead emit an `Ended` marker on > 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 > wait is a real async park; a drained-but-live relay set parks). > - **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 > the round-trip test caught during bring-up); the composed egress > frames the tunnel wire once with the tunnel codec and the RFC @@ -154,7 +154,7 @@ Deliverables: > The composed egress landed: `composed = ["dep:alktunnels"]` > (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_ > MAX_TUNNELS` = 256), per-tunnel relay tasks draining into a shared > inbound queue, `SessionOwner` (the owner wrapper shared with the diff --git a/tasks/implementation/core-consumer-associate.md b/tasks/implementation/core-consumer-associate.md index 9c4eabd..0b91537 100644 --- a/tasks/implementation/core-consumer-associate.md +++ b/tasks/implementation/core-consumer-associate.md @@ -1,6 +1,6 @@ --- id: implementation/core-consumer-associate -name: "Core — consumer ASSOCIATE: AssociateSession + e2e tests" +name: "Core: consumer ASSOCIATE, AssociateSession + e2e tests" status: completed depends_on: [implementation/core-consumer-connect, implementation/core-producer-associate] scope: moderate @@ -20,20 +20,20 @@ Deliverables (port from `/workspace/alksocks-udp-poc/src/consumer.rs`, adapted to the crate's wire codec): 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 addressing** (the handle is the channel; ADR-003). 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. 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 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 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 ends (association lifetime = channel lifetime). 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 mirror invariants) - POC source: `/workspace/alksocks-udp-poc/src/consumer.rs` - (`UdpAssociation` — the reader-state finding's origin) + (`UdpAssociation`, the reader-state finding's origin) ## Notes - **`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 `&TargetAddr`: the same entry shape as `connect`. A `ToTargetAddr for &TargetAddr` impl was added to the forked @@ -81,7 +81,7 @@ adapted to the crate's wire codec): `TargetAddr` value across sends. - **Teardown ownership matches the CONNECT/BIND sessions** (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 plane EOFs structurally (the mux's implicit-EOF sentinel); `shutdown()` half-closes (the graceful path, returning the reap @@ -92,27 +92,28 @@ adapted to the crate's wire codec): - **`recv_datagram` returns `Result>`** (the POC's shape): `Ok(None)` = channel EOF (the association's 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 loud through the wire codec's `WireError` → RFC mapping (`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). - **The pending queue stores decoded datagrams** (`(TargetAddr, - Bytes)`) — the POC stored pre-decode inner bytes; decoded storage - keeps the delivery shape uniform (decode once per frame either - way; no wire.rs change needed — `decode_inner` stays private). + Bytes)`) rather than the POC's pre-decode inner bytes; decoded + storage keeps the delivery shape uniform (decode once per frame + either way; no wire.rs change needed, since `decode_inner` stays + private). - **Unit tests ride a duplex-backed session** (`skip_auth`'s 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 finding's trigger), mid-frame EOF fail-loud, garbage-ATYP fail- loud, and the wire-error mapping table. - **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 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. - **No harness changes**: the consumer-side topology'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` (POC #2's shape, adapted to the crate's teardown-owning session posture): `AssociateSession::associate` (open → no-auth → ASSOCIATE -→ reply, capturing the sentinel BND.ADDR as `relay_addr()` — -received, never used for addressing; the ASSOCIATE DST.ADDR is the +→ reply, capturing the sentinel BND.ADDR as `relay_addr()`, +received but never used for addressing; the ASSOCIATE DST.ADDR is the 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 `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` (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' teardown ownership (Drop sync-reap + structural EOF, `close()` -graceful, teardown-at-birth on in-stream refusals, no `Clone` — -`compile_fail`-asserted). Typed error surface throughout: open +graceful, teardown-at-birth on in-stream refusals, no `Clone`, +since `compile_fail` is asserted there). Typed error surface throughout: open failures are `SocksSessionError::Open` (ADR-049 reasons); the missing-egress 0x07 and factory-failure codes surface from `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 mapping, and the CONNECT+ASSOCIATE regression guard on one 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 --no-deps`, and both wasm checks clean. \ No newline at end of file diff --git a/tasks/implementation/core-consumer-bind.md b/tasks/implementation/core-consumer-bind.md index 620bb55..4172291 100644 --- a/tasks/implementation/core-consumer-bind.md +++ b/tasks/implementation/core-consumer-bind.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/core-consumer-connect, implementation/core-producer-bind] scope: narrow @@ -21,11 +21,11 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/consumer.rs`, adapted to the crate's error surface): 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). 2. **The reply#2 reader (`await_inbound`)**: reply#2 is hand-read 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 (`BindDataPlane`-shaped: `into_inner()`/`get_socket_mut()`). 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 pinned): `BindSession` captures reply#1's BND.ADDR at `bind` (the 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 and returns the borrowed `BindDataPlane` (`Ok(None)` on a spent 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 second codec, ADR-011). Errors ride `SocksSessionError` (the `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 exposes `From`, not the RFC inner enums directly). - `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 plane is a plain pipe (`pump_bidi`-shaped consumers work without 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); `close()` = half-close (the EOF sentinel reaches the producer's pump) then reap. Teardown-at-birth on the in-stream refusal path (command-gate 0x02, factory-failure reply#1): the adopted channel 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. - The quiet-wait garbage path surfaces at `await_inbound` as an `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. -- 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 `echo_accept`/`refusing_accept*` closures cover every test; the 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 shape, adapted to the crate's teardown-owning session posture): `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 -`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`/ `get_socket_mut` + `AsyncRead`/`AsyncWrite` forwards), and the CONNECT session's teardown ownership (Drop sync-reap + structural 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 command gate's 0x02 and factory-failure reply#1 codes surface from `bind` as `Rfc`; accept-stage reply#2 codes surface at diff --git a/tasks/implementation/core-consumer-connect.md b/tasks/implementation/core-consumer-connect.md index 7f67fb8..32c84ba 100644 --- a/tasks/implementation/core-consumer-connect.md +++ b/tasks/implementation/core-consumer-connect.md @@ -1,6 +1,6 @@ --- id: implementation/core-consumer-connect -name: "Core — consumer half: Socks5ChannelSession (CONNECT) + e2e tests" +name: "Core: consumer half, Socks5ChannelSession (CONNECT) + e2e tests" status: completed depends_on: [implementation/core-producer-connect] scope: narrow @@ -12,7 +12,7 @@ level: implementation ## Description 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 client side of the RFC conversation in-stream (POC #1's `use_stream` shape). @@ -22,23 +22,24 @@ consumer.rs`, adapted to the crate's params/error surface): 1. **`Socks5ChannelSession::connect(client, target)`**: opens the 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 - `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 session over a `BiStream` is the intended use). 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 - raw byte pipe — expose `split()` (read/write halves) and + raw byte pipe. Expose `split()` (read/write halves) and `into_inner()` (the underlying `Socks5Stream` for advanced use). 0 B framing (CONNECT). 4. **Teardown ownership** (alktunnels ADR-005 posture, inherited): 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). 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 surfaced through the session type (consumer.md §Error surface). 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/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` - alktunnels template: `/workspace/@alkdev/alktunnels/src/consumer.rs` (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, target)`. `Config::default()` is the no-auth posture (the `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 `&mut Socks5Stream`; the `SplitPipe` type alias), not owned ones: the session keeps teardown ownership for every shape, 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). - Refusal teardown: an in-stream refusal (Rfc error) tears the adopted channel down inside `connect` before the error returns (teardown-at-birth; the phantom-channel property, tested both 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` cannot await) + the structural EOF of the dropped pipe; the graceful path is `close()` (write-half shutdown first, then reap). -- Harness: added the consumer-side topology (`wire_consumer_side[_with]` - — the consumer dials with a real `ChannelClient`; the producer +- Harness: added the consumer-side topology (`wire_consumer_side[_with]`, + where the consumer dials with a real `ChannelClient` and the producer accepts with the real registration) plus `witnessing_echo_dial` (the dial-witness target-form probe). The `allow(dead_code)` attr 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 -Implemented `Socks5ChannelSession` (CONNECT) in `src/consumer.rs` — +Implemented `Socks5ChannelSession` (CONNECT) in `src/consumer.rs`: `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 through `SocksSessionError::Rfc` for refused targets) and teardown-at-birth on the in-stream refusal path. E2E suite (`tests/consumer-connect.rs`, 9 tests over the new consumer-side 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, FORBIDDEN open → typed open error + no session, drop teardown + connection health, half-close EOF semantics, `into_inner` pairing, diff --git a/tasks/implementation/core-crate-deps.md b/tasks/implementation/core-crate-deps.md index ec9332f..1c4d852 100644 --- a/tasks/implementation/core-crate-deps.md +++ b/tasks/implementation/core-crate-deps.md @@ -1,6 +1,6 @@ --- id: implementation/core-crate-deps -name: Core — crate dependencies + module skeleton (alkcall wiring) +name: "Core: crate dependencies + module skeleton (alkcall wiring)" status: completed depends_on: [] scope: narrow @@ -14,35 +14,35 @@ level: implementation Add the dependency surface the wrapper layers need and the module skeleton so the batch-1 core tasks have their targets. This is the 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. 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): - - `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) - `serde = { version = "1", features = ["derive"] }`, `serde_json = "1"` (the params schema + spec builder) - `bytes = "1"` (the wire codec's buffer types) - `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) - 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 - `tokio/net`, no `socket2` — those ride `local` in batch 2. + `tokio/net`, no `socket2`: those ride `local` in batch 2. - `[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. -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: - `params`, `wire`, `producer`, `consumer`, `egress`, `error` — + `params`, `wire`, `producer`, `consumer`, `egress`, `error`; one-line stub files, each with a module doc naming its filling task (the vendor-scaffolding precedent). `rfc1928` stays as is. 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. ## Acceptance Criteria @@ -50,7 +50,7 @@ Deliverables: - [x] `cargo test` passes - [x] `cargo clippy --all-targets -- -D warnings` 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 graph (alkcall 0.8.0's default crate is wasm-clean; verify, do not assume) @@ -80,28 +80,28 @@ Deliverables: self-contained on `wasm32-unknown-unknown`. The `futures = { default-features = false, features = ["std"] }` subset compiles in 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"] }`, `serde_json = "1"`, `bytes = "1"`, and `futures = { version = "0.3", default-features = false, features = ["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 - 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 - (`default = []`, `local = []`, `composed = []` — ADR-009, populated + (`default = []`, `local = []`, `composed = []`; ADR-009, populated 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`, `wire`, `producer`, `consumer`, `egress`, `error` (alphabetical declaration order after `rfc1928`; rustfmt-sorted). No re-exports 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` → core-wire-codec, `egress` → core-seam-types, `producer` → core-producer-connect (+ associates/bind), `consumer` → 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/ composed posture. - Dep-graph spot checks: `cargo tree --target @@ -109,7 +109,7 @@ Deliverables: nothing (not in the wasm normal graph); the normal-only tokio feature set on both native and wasm targets is exactly `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). ## Summary @@ -117,7 +117,7 @@ Deliverables: Added the batch-1 dependency surface and the module skeleton: `Cargo.toml` gains `alkcall = "0.8.0"` (the substrate pin, 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 skeleton modules (`params`, `wire`, `producer`, `consumer`, `egress`, `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 0.8.0's crates.io artifact compiles clean on `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 doc --no-deps`, both wasm checks, and the `mio`/`socket2`/ `tokio::net` absence spot-checks on the default (normal) dependency diff --git a/tasks/implementation/core-integration-suite.md b/tasks/implementation/core-integration-suite.md index bac699f..3f3c174 100644 --- a/tasks/implementation/core-integration-suite.md +++ b/tasks/implementation/core-integration-suite.md @@ -1,6 +1,6 @@ --- id: implementation/core-integration-suite -name: Core — cross-cutting integration suite + docs sync +name: "Core: cross-cutting integration suite + docs sync" status: completed depends_on: [implementation/core-consumer-associate, implementation/core-consumer-bind] scope: moderate @@ -25,7 +25,7 @@ Deliverables: served on the same `channels/socks5/sub` op, interleaved sessions on one connection. - **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). - **Teardown semantics matrix**: client half-close ends a CONNECT data plane cleanly (the producer's pump exits, the connection @@ -36,7 +36,7 @@ Deliverables: - **Identity/scope matrix**: `socks5:open`-only (CONNECT + ASSOCIATE yes, BIND 0x02), `socks5:bind`-only (BIND yes, 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. - **The readiness contract** re-pinned at the integration level (the producer-associate task owns the unit-level test; this @@ -47,12 +47,12 @@ Deliverables: follow the decomposition phase" line updated). - Any spec drift the implementation surfaced (naming, shapes, 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 (producer + consumer + wire + seams), pointing at the specs. 3. **Full verification sweep** (the batch gate's input): `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 checks, and `cargo doc --no-deps` (docs changed). @@ -64,7 +64,7 @@ Deliverables: CONNECT/ASSOCIATE FORBIDDEN at the open gate"). The landed ADR-006 + implementation pin the open op's `required_scopes = ["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 phantom channel), and a BIND-capable deployment grants both scopes explicitly. The suite pins the landed posture @@ -72,28 +72,28 @@ Deliverables: producer- and consumer-side; ADR-006 §Consequences now states the asymmetry explicitly (never implied, in either direction). The 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. - **Test-shape notes:** the suite rides the established - `tests/harness.rs` topologies (the raw producer-side topology — - the producer's serving side allocates channel ids — and the - consumer-side topology — the consumer allocates; the mux/demux is + `tests/harness.rs` topologies (the raw producer-side topology, + where the producer's serving side allocates channel ids; and the + consumer-side topology, where the consumer allocates; the mux/demux is pinned in both allocation directions). The mixed-command concurrency tests box the per-command round-trip futures into one - `Vec` (`Pin>` — the three async fns + `Vec` (`Pin>`; the three async fns have distinct opaque types, so a shared boxed type is the `join_all` vehicle). The BIND client-gone row uses a parked accept factory so the quiet-wait exit is the only possible end (the deterministic client-gone probe). - **Readiness contract under load:** the integration-level pin reuses the producer-associate unit test's hot-`Ok(None)` egress - shape across concurrent associations (with a CONNECT interleaved) - — the stage's re-park tolerance must hold concurrently, not just + shape across concurrent associations (with a CONNECT interleaved), + so the stage's re-park tolerance must hold concurrently, not just sequentially. ## 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 to end over the channels harness (the alkcall 0.8.0 establishment surface, both topology directions): @@ -105,7 +105,7 @@ surface, both topology directions): ends only its own session (mux/demux isolation). 2. **Concurrency**: nine concurrent mixed-command sessions over one 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 after the burst; no channel entry leaks on either side. 3. **The teardown matrix** (wire.md §Sentinels and lifecycle): a @@ -126,13 +126,13 @@ surface, both topology directions): branch. 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 batch-1 pin, batch 2 (`local`/`composed`) as the follow; the "remaining layers follow the decomposition phase" residue gone. `consumer.md` §Wiring reconciled to the landed signatures (`&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 states the grant asymmetry explicitly. `src/lib.rs` crate docs rewritten to describe the landed surface (rfc1928 fork, wire codec, @@ -161,8 +161,8 @@ features `local`/`composed` compile), `cargo clippy --all-targets -- ## Notes -> Filled by the implementation agent — see the Notes above. +> Filled by the implementation agent: see the Notes above. ## Summary -> Filled on completion — see the Summary above. \ No newline at end of file +> Filled on completion: see the Summary above. \ No newline at end of file diff --git a/tasks/implementation/core-params-errors.md b/tasks/implementation/core-params-errors.md index 1f4a671..369f3f4 100644 --- a/tasks/implementation/core-params-errors.md +++ b/tasks/implementation/core-params-errors.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/core-crate-deps] scope: narrow @@ -12,7 +12,7 @@ level: implementation ## Description 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: `/workspace/@alkdev/alktunnels/src/params.rs` (the sibling's spec builder + scope-constant posture). @@ -25,19 +25,19 @@ Deliverables: "socks5:open"`, `SOCKS5_BIND_SCOPE = "socks5:bind"` (ADR-001/ ADR-006; the scope-string convention `: `; 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**: 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, minimum 1}`; `required_scopes = ["socks5:open"]`. Follow the alktunnels spec-builder shape exactly (the registry validates 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. -2. **`src/error.rs`** — the typed open-error surface (alkcall +2. **`src/error.rs`**: the typed open-error surface (alkcall 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` / `unknown_resource` / `resource_shortage` / `handler_error` / `timeout`) plus the registry's `FORBIDDEN`/`INVALID_INPUT` @@ -45,15 +45,15 @@ Deliverables: `establishment_reason` helper. - The session-level error type for the consumer sessions (`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 surface (a refused session never yields a phantom channel; a 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). 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 error-reason mapping table. @@ -91,16 +91,16 @@ error-reason mapping table. (Sub/External/channel-open marker on `alk/socks5`; input schema `{type: object, additionalProperties: false}`; reply schema `channel_id` integer minimum 1 required; `required_scopes = - ["socks5:open"]`), and the `establishment_reason` helper re-export - (the alktunnels posture — delegates to + `["socks5:open"]`), and the `establishment_reason` helper re-export + (the alktunnels posture, which delegates to `ChannelOpenError::establishment_reason`). - `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 `channel:open_failed`/`FORBIDDEN`/`INVALID_INPUT` classes), the typed `SocksEstablishmentReason` vocabulary (ADR-049 §3's five - codes, `parse`/`as_str`; unknown codes are `None` — never silently - aliased, the vocabulary is one-way), `SocksOpenError::reason()` as + codes, `parse`/`as_str`; unknown codes are `None`, never silently + aliased, since the vocabulary is one-way), `SocksOpenError::reason()` as the typed view, and `SocksSessionError` (open errors vs in-stream `Rfc(SocksError)` vs `Io`; the RFC reply-code refusals ride the 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). - Deliberate deviations from the task text: the params struct 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 mapping table is testable in both directions; unknown reason codes do NOT alias a variant (one-way vocabulary discipline). - 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. ## Summary @@ -130,7 +130,7 @@ open-error vocabulary: `socks5:open`/`socks5:bind`, trivial empty-object input schema with loud `additionalProperties: false`, reply `{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. - `error`: `SocksOpenError` mapping the ADR-049 §4 reasons (`dial_failed`/`unknown_resource`/`resource_shortage`/ diff --git a/tasks/implementation/core-producer-associate.md b/tasks/implementation/core-producer-associate.md index 64e6f77..2c17628 100644 --- a/tasks/implementation/core-producer-associate.md +++ b/tasks/implementation/core-producer-associate.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/core-wire-codec, implementation/core-producer-connect] 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` + `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 - channel is the relay handle, ADR-003) — then the datagram stage. -2. **The datagram stage** — `select!`-driven (channel reads ∥ egress + channel is the relay handle, ADR-003), then the datagram stage. +2. **The datagram stage**, `select!`-driven (channel reads ∥ egress replies), with the POC's three load-bearing findings as 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 also the honest full-duplex shape). - **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 egress returning instant `Ok(None)` must not starve the stage's 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`). 3. **The `EgressFactory` lifecycle**: called once per ASSOCIATE; 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). 4. **Missing-egress refusal**: a registration without an `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 → sentinel reply → datagram round-trip through the echo egress (in-order, multiple datagrams, one association); empty-payload datagram legal (not EOF); undecodable frame ends the association 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; CONNECT still served on the same registration (the regression guard). @@ -86,14 +86,15 @@ Deliverables (port from `/workspace/alksocks-udp-poc/src/producer.rs` ## 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 pre-encoded inner bytes from the egress; the forward-path `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. - **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 ∥ `egress.next_reply()`, returning a typed `StageExit` diagnostic (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 egress `Err` end the association loud; a reply the joint bound 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 (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 missing-dial refusal. Egress-factory failure (post-command, 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 consumer-associate task; its `send_datagram` will frame via `frame()` and its `recv_datagram` will decode via the persistent - `DatagramReader` — both already exported. + `DatagramReader`; both are already exported. ## 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 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 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`) pins that an instantly-ready `Ok(None)` egress must not starve the channel branch (the POC's load-bearing API finding, 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 payload) and writes frames via the new `frame_inner` (egress `next_reply`'s inner bytes → the channel); the egress never sees a length prefix. 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 RFC-faithfully (0x07); egress-factory failure maps to reply#1's code. CONNECT still serves on the same registration (regression diff --git a/tasks/implementation/core-producer-bind.md b/tasks/implementation/core-producer-bind.md index d6393c7..5fe4b7c 100644 --- a/tasks/implementation/core-producer-bind.md +++ b/tasks/implementation/core-producer-bind.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/core-producer-connect] 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` 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 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. 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 (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). - **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 - teardown riding the accept future's drop). **Not a loop** (the - POC's `never_loop` finding — exactly two exits). Any byte in + teardown riding the accept future's drop). **Not a loop** (the + POC's `never_loop` finding: exactly two exits). Any byte in the quiet wait is protocol garbage → the session ends with no data plane. - **reply#2 carries the peer address** (the accepted handle's 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. 3. **Failure mapping is RFC-faithful end to end**: factory failure (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. 4. **The establishment bound covers only the open-op round trip** (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 establishment refusals). 5. **Missing-accept refusal**: a registration without a `BindAcceptFactory` serves the wrapper-aware in-band default - (sentinel reply#1 + sentinel reply#2 — ADR-007 variant 3) or - refuses — the ADR names variant 3 as the in-band default when + (sentinel reply#1 + sentinel reply#2, ADR-007 variant 3) or + refuses; the ADR names variant 3 as the in-band default when neither provider is wired; implement variant 3 as the no-factory default and document the choice. 6. **Tests** (via the harness + raw RFC client): BIND → reply#1 → (the harness accept resolves) → reply#2 with the peer → pass-through echo both directions; the command gate (a `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 - 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 code; accept-stage failure → reply#2 mapped code, no data plane; CONNECT + ASSOCIATE regression guard (one registration, all @@ -97,32 +97,32 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs` ## Notes - **The gate seam**: `serve_socks5_over` 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 `socks5:bind` scope, never implied by `socks5:open`); the substrate-free entry takes the resolved boolean explicitly, so the gate is caller-policy at the raw-entry shape and identity-resolved at the channels shape. A scope-less BIND gets `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). - **Variant 3 as the no-factory default** (documented in `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 future's instant resolution, so the full phase model is preserved: reply#1 → quiet wait → reply#2 → pass-through) + the sentinel 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. - **The quiet wait** is a single-shot `select!` with exactly two 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 garbage → `UnexpectedUdpControlGarbage`, no reply#2, no data plane). Not a loop (the POC's `never_loop` finding). - **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 unspecified with the port. - **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, then `Bug("bind accept refused in-stream")`. No phantom listeners, no 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 wrapper never bounds (the establisher's 10s bound is pre-handler); 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 factory (`std::future::pending`) so the garbage/EOF branch wins 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). -- **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 integration tests: round-trip over channels, the gate 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 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 -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 (two exits; any byte = garbage, EOF = client-gone teardown riding the accept future's drop) → reply#2 carrying the peer → `pump_bidi` diff --git a/tasks/implementation/core-producer-connect.md b/tasks/implementation/core-producer-connect.md index b2c9550..7179bcd 100644 --- a/tasks/implementation/core-producer-connect.md +++ b/tasks/implementation/core-producer-connect.md @@ -1,6 +1,6 @@ --- id: implementation/core-producer-connect -name: "Core — producer half: establisher + dispatch + registration + CONNECT" +name: "Core: producer half, establisher + dispatch + registration + CONNECT" status: completed depends_on: [implementation/core-seam-types, implementation/core-params-errors, implementation/core-test-harness] scope: moderate @@ -14,7 +14,7 @@ level: implementation Implement the producer half of `src/producer.rs` per producer.md: the session-gate establisher, the command-dispatch pump handler, the 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. 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): 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 - `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 establisher→handler; POC #1's wire note). Optional `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: - **CONNECT** → invoke `DialFn(target)` at the command-read 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: `reply_error` with the mapped RFC code (dial-failure → - `ConnectionRefused`; scope-class → `ConnectionNotAllowed`) — no + `ConnectionRefused`; scope-class → `ConnectionNotAllowed`), no phantom data plane. - **UDP ASSOCIATE / TCP BIND** → this task lands the dispatch arms as RFC-faithful refusals (`CommandNotSupported`); the associate/bind producer tasks replace them. (The dispatch - skeleton exists from day one so the regression guard — one - registration, all three commands — is testable early.) - - The handler is substrate-agnostic by construction — it sees + skeleton exists from day one so the regression guard (one + registration, all three commands) is testable early.) + - The handler is substrate-agnostic by construction: it sees boxed halves, never a socket type. 3. **`register_socks5_openable`**: spec + establisher + pump via `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 refuse RFC-faithfully (`CommandNotSupported`-class), never a panic (ADR-008). -4. **`serve_socks5_over`** — the substrate-free protocol entry +4. **`serve_socks5_over`**: the substrate-free protocol entry point (the POC shape): generic over `T: AsyncRead + AsyncWrite + Unpin`, driving the typestate and dispatching. This is what makes 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): > -> - `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 > `Socks5Producer::builder()` sketch); `register_socks5_openable` > 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. > - The `establisher` accepts `Option` (the > 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. > - The ASSOCIATE arm carries the `EgressFactory` parameter (and the > 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 > callers). Until those tasks land, the seams are accepted and > 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 > `SocksServerError::Bug("dial refused in-stream")` after > `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 > 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 > 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 > plane (`DialFn` at the command-read interception point → > `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. > > 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/ > 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 > channels with the identity witness, dial refusal 0x05 with no > 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 > harness's `RegisterMode` gained the real-producer mode; the > minimal CONNECT stand-in is retained only as a harness-internal diff --git a/tasks/implementation/core-seam-types.md b/tasks/implementation/core-seam-types.md index 0de5413..8677c57 100644 --- a/tasks/implementation/core-seam-types.md +++ b/tasks/implementation/core-seam-types.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/core-crate-deps] scope: moderate @@ -14,50 +14,50 @@ level: implementation Implement `src/egress.rs` per producer.md §The Three Seams and ADR-004: the three I/O-plane seam types as function-shaped callbacks 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. -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-udp-poc/src/producer.rs`: - - `TargetHandle { read, write }` — boxed halves + - `TargetHandle { read, write }`, i.e. boxed halves (`Box`, `Box`; `+ Sync` per the ChannelPlan payload bound). Ownership rides the handle structurally (ADR-004). - - `DialFn` — `Arc BoxFuture<'static, + - `DialFn`: `Arc BoxFuture<'static, Result> + Send + Sync>`. - `SocksDialError` with faithful `to_reply_error()` mapping (refused → `ConnectionRefused`, not-allowed → `ConnectionNotAllowed`, unreachable → `NetworkUnreachable`, - general → `GeneralFailure` — the POC's mapping, RFC-faithful + general → `GeneralFailure` (the POC's mapping, RFC-faithful per AGENTS.md convention 2). -2. **The egress seam (ASSOCIATE)** — from +2. **The egress seam (ASSOCIATE)**, from `/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* bytes (`addr + data`). - **The readiness contract is load-bearing API surface** (POC #2's 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 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. - `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`: - `BindAccept { bind_addr: SocketAddr, accept: BoxFuture<'static, - Result> }` — reply#1's + Result> }`: reply#1's BND.ADDR carrier + the one-inbound-accept future. **The accept future owns its resources end-to-end**; the quiet wait's `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 - witness — no `_` bindings of owning values). - - `BindAcceptFactory` — called once per BIND with the requested + witness: no `_` bindings of owning values). + - `BindAcceptFactory`: called once per BIND with the requested target. - `BindAcceptError` (accept-stage, post-reply#1 → reply#2) and `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. - **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 BoxFuture<'static, Result> + Send + Sync>`), and `SocksDialError` with the POC #1 five-variant mapping @@ -126,16 +126,16 @@ with the producer-associate task (it needs the stage). planned. - **Accept (BIND)**: `BindAccept {bind_addr, accept}` (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}` - (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 is the substrate-agnostic way to carry the POC's `_session` without naming a substrate type in the protocol layer), `BindAcceptFactory`, and the two-stage error pair (`BindAcceptFactoryError` pre-reply#1 → reply#1; `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 threshold (three real trait impls) is stated in the module docs. - 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). - Deviations from the POC code, all deliberate: `BindTargetHandle` drops the POC's `_session: Option` 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 - instead of a public field (the accept future is already private — - the struct now encapsulates both), and the test halves use + instead of a public field (the accept future is already private, + so the struct now encapsulates both), and the test halves use `tokio::io::duplex` (test-only; the POCs' `std::io` empty/sink do not implement tokio's traits). @@ -157,10 +157,10 @@ with the producer-associate task (it needs the stage). > 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` 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 load-bearing readiness contract documented in rustdoc; `EgressFactory` + the `egress_factory` sync-lift constructor; `EgressError`), and the diff --git a/tasks/implementation/core-test-harness.md b/tasks/implementation/core-test-harness.md index 35922ae..2896f18 100644 --- a/tasks/implementation/core-test-harness.md +++ b/tasks/implementation/core-test-harness.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/core-seam-types, implementation/core-params-errors] scope: moderate @@ -11,8 +11,8 @@ level: implementation ## Description -Build `tests/harness.rs` — the producer↔consumer wiring over a -`tokio::io::duplex` carrying the channels 8-byte chunk format — so +Build `tests/harness.rs`, the producer↔consumer wiring over a +`tokio::io::duplex` carrying the channels 8-byte chunk format, so every subsequent core task tests end-to-end without the `local` 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 @@ -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 the producer task builds on it. Template: `/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): -- **producer** (connect side of the channels connection — runs +- **producer** (connect side of the channels connection; runs `ChannelClient::from_connection_with_serving`): serves the socks5 open op + generic channel ops on its serving registry. Its manager - allocates the channel IDs (`ChannelSide::Connect` — the serving - side allocates; the initiator adopts). + allocates the channel IDs (`ChannelSide::Connect`, since the serving + side allocates and the initiator adopts). - **consumer** (accept side): the `ChannelsAdapter` + capturing install hook. The hook captures the consumer's channel-0 `CallConnection` and `ChannelManager`. - **The POC #1 wiring requirement is load-bearing here** (producer.md §Harness wiring requirement): the accept-side install hook must 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 wiring). The harness makes the requirement explicit and tested. Deliverables: 1. **Identities**: `consumer_identity()` (`socks5:open` only), - `bind_identity()` (`socks5:bind` only — the command-gate probe), - `both_scopes_identity()` (both grants, explicitly — never implied, + `bind_identity()` (`socks5:bind` only, the command-gate probe), + `both_scopes_identity()` (both grants, explicitly and never implied, ADR-006), plus a `TestIdProvider` resolving them (the alktunnels 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): - - `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 `refusing_dial()` (a failing dial for the refusal-mapping tests). - - `echo_egress()` — an `EgressFactory` whose egress echoes + - `echo_egress()`: an `EgressFactory` whose egress echoes datagrams back (the POC #2 shape, readiness-contract-clean: 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 failing variant. 3. **The producer registration helper**: a `spawn_producer(...)` 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 tests. 4. **A raw RFC client helper**: a hand-rolled minimal RFC 1928 client over any `AsyncRead + AsyncWrite` (greeting → method - selection → command with target → reply read) — the differential - suite's established pattern — so producer tasks test without + selection → command with target → reply read) following the differential + suite's established pattern, so producer tasks test without depending on the consumer session types (which land later). 5. **One smoke test** proving the full loop: harness up → producer registered → consumer opens a channel → raw RFC CONNECT conversation → echo round-trip. (This test needs a minimal CONNECT producer path; if the producer task hasn't landed, stub 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 - 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 compiling with the identities + seams + helpers tested.) ## Acceptance Criteria - [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) - [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] The raw RFC client helper round-trips a greeting + command + 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 - (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) ## References @@ -112,7 +112,7 @@ Deliverables: - `tests/harness.rs`: the producer↔consumer wiring over one `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 allocates the odd channel IDs), **consumer = accept side** (the `ChannelsAdapter` + capturing install hook; the hook captures the @@ -126,15 +126,16 @@ Deliverables: channel survives on either side (fail-closed). - Identities: `consumer_identity()` (`socks5:open` only), `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 directions). - 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 - reconstruction — the seam errors are not `Clone`), + reconstruction, as the seam errors are not `Clone`), `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), `refusing_egress(err)`, `echo_accept()` (reply#1 advertises a static in-process BND.ADDR, `ECHO_BIND_PORT` 2121; the accept @@ -144,7 +145,7 @@ Deliverables: - `register_socks5_openable_minimal` + `socks5_session_establisher`: the producer-side wiring helper over alkcall 0.8.0's `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 per-call opener identity id (CF-006) into an `IdentityWitness`, asserted end-to-end in the smoke test. @@ -168,37 +169,37 @@ Deliverables: `consumer_call.call(OP_SOCKS5_OPEN, Socks5Params::as_json())` and adopts on `consumer_manager` (the alktunnels `wire_with` + `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 side's channel-0 call surface; `open_channel` remains the 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. ## Summary > 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 = connect side/serving; consumer = accept side/adapter + capturing hook), the alkcall 0.8.0 establishment surface consumed for the 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 three identities + `TestIdProvider` (all unit-tested). - 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)`) / `refusing_egress`, `echo_accept` (immediate resolve + echo) / `refusing_accept_factory` / `refusing_accept` (two-stage refusal shapes, mapping-table-verified through `to_reply_error()`). - 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 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 byte-level server (success + refusal replies). - 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 clippy --all-targets -- -D warnings` clean, `cargo fmt --check` clean, `cargo doc --no-deps` zero warnings, both wasm checks -(`cargo check` + `cargo clippy` on `wasm32-unknown-unknown`) clean — -the harness stays out of the wasm build. \ No newline at end of file +(`cargo check` + `cargo clippy` on `wasm32-unknown-unknown`) clean, +and the harness stays out of the wasm build. \ No newline at end of file diff --git a/tasks/implementation/core-wire-codec.md b/tasks/implementation/core-wire-codec.md index 67b6656..33001d6 100644 --- a/tasks/implementation/core-wire-codec.md +++ b/tasks/implementation/core-wire-codec.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/core-crate-deps] 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 ASSOCIATE datagram stage (ADR-003), re-implemented in first-party 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 plus data, not a bare payload). 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 (`addr + data`); the producer's frame writer adds the single length 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: 1. **`frame(addr: &TargetAddr, payload: &[u8]) -> Result`** — 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 + addr + port) followed by the payload. 2. **The joint bound (the load-bearing constraint)**: reject `Oversize` at frame time whenever `len(addr) + len(payload) > 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 destinations, 65516 for IPv6, 65528 for IPv4. No unchecked u16 arithmetic on the payload bound (wire.md's explicit requirement). Expose `addr_encoding_len(addr)` (7 v4 / 19 v6 / 259 max domain) 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 (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 `addr + data`. Infallible decode direction (the oversize bound is enforced at frame time); `is_mid_frame()` diagnostic for teardown. 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 matters is payload-level. See the doc-sync deliverable.) 5. **Doc-sync (wire.md + bast.md residue)**: both docs say "`len = 0` 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 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. 6. **EOF layering note in the module docs**: EOF is the 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 case), oversize at frame time, mid-frame state observable, garbage 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). ## Acceptance Criteria @@ -97,7 +97,7 @@ producer stage can fail the association loud). - docs/architecture/decisions/003-udp-handle-swap.md (framing-once, F-2) - 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` (`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 infallible (oversize enforced at frame time bounds the buffer by 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), - `ShortAddress`, `DomainNotUtf8`, `EmptyFrame` (wire `len == 0` — + `ShortAddress`, `DomainNotUtf8`, `EmptyFrame` (wire `len == 0`, unreachable from a compliant peer; protocol garbage). The reader consumes the frame's bytes either way and keeps going; the caller fails the association loud on the first error. - **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 `MAX_INNER_LEN`. `addr_encoding_len` exposes 7 (v4) / 19 (v6) / `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 / 65528 v4) and one-over rejection. - **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, payload = the remainder. `TargetAddr::to_be_bytes()`'s output is 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 a legal empty datagram, never EOF"), with `len == 0` (zero inner 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 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. ## 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 doc --no-deps`, `cargo check --target wasm32-unknown-unknown`, `cargo clippy --target -wasm32-unknown-unknown -- -D warnings` — all clean. \ No newline at end of file +wasm32-unknown-unknown -- -D warnings`: all clean. \ No newline at end of file diff --git a/tasks/implementation/door-tcp.md b/tasks/implementation/door-tcp.md index a4f67fd..6d88650 100644 --- a/tasks/implementation/door-tcp.md +++ b/tasks/implementation/door-tcp.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/local-providers] scope: moderate @@ -12,23 +12,23 @@ level: implementation ## Description 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), plus the BIND door's reply#2 fidelity. The door is the vanilla↔ACL boundary; the RFC codec lives only here. 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: - **The NoAuth door** (POC #1's raw-pipe shape): bind a real `TcpListener`; per accepted connection, pipe it raw onto a channel `BiStream` (`copy_bidirectional`, **zero protocol 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. - **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` → `negotiate_auth(&[PasswordAuthentication])` → `read_username_password` → check → `accept`/`reject`), then @@ -46,18 +46,18 @@ Deliverables: - **The identity mechanism**: the credential→identity mapping yields an alkcall `Identity` (scopes included); the door opens 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): 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 (`CallConnection::call_with_payload` carries `auth_token`; `ServingConfig.identity_provider` resolves it producer-side) 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 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, consumer.md §The BIND door's reply#2 fidelity). The BIND door 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 accepted connection that sends no first byte within a 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 constructor config. Keep the v1 shape simple: an idle-first-byte 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()` → `serve(channel_client)` (the NoAuth door omits `.credentials`). `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). 2. **Tests** (`tests/door-tcp.rs`): a vanilla RFC 1928 client (the differential-suite raw-client pattern) through the NoAuth door → @@ -94,18 +94,18 @@ Deliverables: --check` clean - [x] Default wasm checks stay green (door code is `local`-gated) - [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 - 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 producer as greeting bytes) - [x] The credential→identity mapping is the static config table (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) - [x] The idle-first-byte timeout + session cap land as constructor - config — pinned by tests -- [x] Per-connection failure isolation — pinned by test + config, pinned by tests +- [x] Per-connection failure isolation, pinned by test ## References @@ -118,26 +118,26 @@ Deliverables: connect_poc.rs` (`front_door_raw_pipe_vanilla_client`), `/workspace/ alksocks-bind-poc/tests/bind_poc.rs` (`bind_vanilla_front_door`) - 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) ## Notes > 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` payload (CF-005 precedence (1)), never a per-credential `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 field, ADR-017 §7) and each credential's open rides the payload `auth_token`; the producer's `ServingConfig.identity_provider` resolves it and the ACL gate reads the resolved scopes exactly as 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, - 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` single-socket posture cannot afford. - **New consumer surface (`src/consumer.rs`)**: `TokenClient` (the @@ -150,22 +150,22 @@ Deliverables: `open_and_negotiate`: open-with-token → adopt → negotiate → teardown-at-birth on failure). `Socks5ChannelSession` gained the `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. - **The door (`src/door.rs`, feature `local`)**: - `FrontDoor::builder().credentials(...).bind(...).build()` → `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). - **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). - **The cred-bearing door**: the forked `accept_password_auth` shape locally (`negotiate_auth(&[ PasswordAuthentication])` → `read_username_password` → check → `accept`/`reject`), the checker closing over the 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 in-band sessions with the mapped identity's id as the token. 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() .bind_addr`, reply#2 from `await_inbound()`'s plane peer; 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 door's RFC half; the in-band path re-speaks them from the parsed 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). - **The BIND door**: same listener shape, riding `BindSession::bind_with_token`; the quiet wait rides the session's `await_inbound`; reply#1's advertised address passes through unchanged (the door adds nothing). - 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 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 beyond the bound is refused immediately, no channel opened) as constructor config. Defaults: 30s / 256. - **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 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); `wire_consumer_side_with` delegates unchanged. - **Test-provider note**: the test `DoorTokenProvider` resolves @@ -208,14 +208,14 @@ Deliverables: > Filled on completion (2026-09-25). 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 door (RFC 1929 terminates locally through the forked `accept_password_auth` shape; the static credential→identity table maps the accepted credential; the SOCKS conversation re-originates in-band via the token-carrying consumer sessions with the vanilla 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 + reply#2 + the pass-through data plane). The identity mechanism decided + recorded: the per-credential identity rides the open-op diff --git a/tasks/implementation/door-udp.md b/tasks/implementation/door-udp.md index 6623fcd..a2f3629 100644 --- a/tasks/implementation/door-udp.md +++ b/tasks/implementation/door-udp.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/door-tcp] scope: narrow @@ -12,22 +12,22 @@ level: implementation ## Description 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 (ADR-003); the in-band wire carries `{addr, data}` frames. Deliverables: -1. **`src/door.rs` additions** (feature `local`) — the UDP door: - - Bind a real UDP socket (the relay socket — the only place a +1. **`src/door.rs` additions** (feature `local`), the UDP door: + - Bind a real UDP socket (the relay socket, the only place a real address exists; the vanilla client `sendto()`s it). - 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 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 - (`parse_udp_request`), **FRAG ≠ 0 → refuse** (drop the - datagram; the v1 posture — fragmentation cannot be expressed + (`parse_udp_request`), and **FRAG ≠ 0 → refuse** (drops the + datagram; the v1 posture, since fragmentation cannot be expressed in-band), sends `{addr, data}` in-band (`send_datagram`); channel→socket encodes the reply header (`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 socket closes with it. A per-client idle bound (no datagrams 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. - The door's bind address is constructor config (consumer.md: "the UDP door's bind address is assembly-layer configuration"). - 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 in Notes; the spec leaves the exact constructor shape open). 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 echo back RFC-header'd (the vanilla client simulated at the RFC boundary); FRAG ≠ 0 refused (dropped, association stays healthy); 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. ## Acceptance Criteria @@ -60,14 +60,14 @@ Deliverables: --check` clean - [ ] Default wasm checks stay green (door code is `local`-gated) - [ ] The RFC UDP-header codec appears only at the door boundary - (in-band stays `{addr, data}` frames) — pinned by test -- [ ] FRAG ≠ 0 refused at the door — pinned by test + (in-band stays `{addr, data}` frames), pinned by test +- [ ] FRAG ≠ 0 refused at the door, pinned by test - [ ] 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) - [ ] Association lifetime = channel lifetime; teardown leaves the - connection healthy — pinned by test -- [ ] The idle bound is constructor config — pinned by test + connection healthy, pinned by test +- [ ] The idle bound is constructor config, pinned by test ## References @@ -76,8 +76,8 @@ Deliverables: - docs/architecture/consumer.md §The Front Doors (the UDP door), §Wiring - POC reference: `/workspace/alksocks-udp-poc/tests/udp_poc.rs` - (`udp_front_door_vanilla_client_round_trip` — the pump shape) -- Landed surfaces: `src/consumer.rs` (`AssociateSession` — + (`udp_front_door_vanilla_client_round_trip`, the pump shape) +- Landed surfaces: `src/consumer.rs` (`AssociateSession`, `send_datagram`/`recv_datagram`/`split`), `src/rfc1928/mod.rs` (`new_udp_header`/`parse_udp_request`), `src/wire.rs` (the frame codec) @@ -86,34 +86,34 @@ Deliverables: - **Surface shape** (the spec left the constructor open): a **sibling** `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 association), and the shared shape would have forced a mode split on every knob. `serve(channel_client)` + `serve_on(socket, client)` (the test/assembly seam), mirroring the TCP door. - **The pump shape** (an implementation finding worth recording): the 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 - instead **splits the association's stream** (`AssociateSession::split` - — the door bridge seam): one pump owns the in-band write half + instead **splits the association's stream** (`AssociateSession::split`, + the door bridge seam): one pump owns the in-band write half (socket→channel, framing via the wire codec), the other the read half (channel→socket, the incremental `DatagramReader` decode). The two pumps join via `select!` (whichever ends first closes the door); the pump is inlined in `serve_on` because the split halves borrow the session (the borrow must end before the teardown's `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 case is the session's own `recv_datagram` contract (tested in consumer-associate). - **The client's source address** is learned from the first inbound 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). - **FRAG ≠ 0** drops the datagram and keeps the association (the v1 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 construction). - **The idle bound** covers the socket→channel pump's @@ -124,7 +124,7 @@ Deliverables: ## Summary 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 (`AssociateSession::associate`; the sentinel BND.ADDR received, never 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 idle bound is constructor config and closes an idle association 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 health, fresh-association-after-teardown). Verification: `cargo test` green, `cargo test --all-features` green, clippy `-D warnings` clean diff --git a/tasks/implementation/fix-bind-reply2-reentry.md b/tasks/implementation/fix-bind-reply2-reentry.md index 5d66b33..c75dea3 100644 --- a/tasks/implementation/fix-bind-reply2-reentry.md +++ b/tasks/implementation/fix-bind-reply2-reentry.md @@ -1,6 +1,6 @@ --- 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 depends_on: [] 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 (`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` (`src/consumer.rs:757-763`) takes `&mut self`, leaves `inner` 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 are the accepted peer's raw application data): a baffling `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. 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 shape, which the method's doc *already* claims: "`None` after a 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 session stays the teardown owner for the channel entry. Either mechanism is acceptable; the spent shape must match the session - family's posture (`Ok(None)` or the typed `SpentSessionError` — - pick one, document it). + family's posture (`Ok(None)` or the typed `SpentSessionError`; pick + 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 - 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 `reply1()` as a view, demote `BindReply1::await_inbound` to a 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 session's method delegates to it and the handle is tested through). The alternate path must be impossible to double-read (private, 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` → negotiated → teardown-on-err; `src/consumer.rs:121-149`, `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 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 - `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); `clear_all` aborts instead (the substrate's abrupt path). No code change; prevents a future reader "fixing" the handle into an @@ -83,7 +83,7 @@ accept-failure mappings unaffected). data-plane bytes asserted untouched - [ ] The `reply1()`-twice re-entry path is closed (the D-1 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 "`None` after a taken plane" claim is true of the code) - [ ] 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 session; the `BindReply1` handle's spec'd intent) - tasks/implementation/review-protocol-core.md (R-2's - `SpentSessionError` shape — the consistency target) + `SpentSessionError` shape, the consistency target) ## Notes @@ -109,34 +109,34 @@ accept-failure mappings unaffected). - **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 (`inner.take()`); the returned `BindDataPlane` **owns** the - `Socks5Stream` while the session stays the teardown owner — the + `Socks5Stream` while the session stays the teardown owner; that is the `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 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 shape chosen is **`Ok(None)`** (not a typed `SpentSessionError`): the method's existing return type `Result>` already carried the 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 taking-methods' (`into_inner`) shape. - **A failed reply#2 read restores the inner** (the phase is *not* 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 existing tests pin), and the session's `split()`/`close()` keep the pipe. The corruption path is the *post-success* re-entry, and that is exactly what the take-guard closes: after success the plane is 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 same way (the quiet-wait garbage test pins this shape, green 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 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 is exactly how the four existing call sites use it. `BindReply1` stays 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 the teardown-at-birth invariant. The three constructors are one-liners 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 were the two test-side ownership consequences above). - **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 graceful shape; `clear_all` aborts instead, the substrate's abrupt path). - **Test round**: the pinning integration test - (`double_await_inbound_returns_the_spent_shape_and_bytes_survive` — the - second call returns `Ok(None)` and the first plane's exchange completed + (`double_await_inbound_returns_the_spent_shape_and_bytes_survive`, + 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 - (`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 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 (`BindSession` + owned `BindDataPlane`). The existing BIND suite stayed green unchanged apart from the one `into_inner` ownership call site @@ -177,7 +177,7 @@ accept-failure mappings unaffected). ## Summary 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 `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 diff --git a/tasks/implementation/fix-coverage-residue.md b/tasks/implementation/fix-coverage-residue.md index 62ebaac..1c6ad41 100644 --- a/tasks/implementation/fix-coverage-residue.md +++ b/tasks/implementation/fix-coverage-residue.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/fix-bind-reply2-reentry, implementation/fix-producer-gate-policy, implementation/fix-rfc1928-posture] scope: moderate @@ -11,7 +11,7 @@ level: implementation ## 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 `docs/reviews/002-protocol-core-implementation-review.md` §N-8 so 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): 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 post-reply): - `ReplyFrameRejected` (an egress reply violating the joint bound) - `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 `egress_factory_called_once_and_dropped_with_the_stage` gives the happy path - the stage's `Io` exit (a failing write/read) 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 (consumer.md), load-bearing for the batch-2 UDP/BIND doors. One - test each (or the explicit "batch-2 door exercises it" note — - prefer the test; the doors are not here yet). + test each (or the explicit "batch-2 door exercises it" note; + prefer the test, since the doors are not here yet). **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 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 - dropped with the session — pin the fail-loud-drops-pending + dropped with the session; pin the fail-loud-drops-pending 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`). -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 - these — one shutdown test). -7. `consumer.rs:843` — `read_reply2`'s version-mismatch arm (a - foreign producer's non-5 version reply#2 — one unit test on + these; one shutdown test). +7. `consumer.rs:843`: `read_reply2`'s version-mismatch arm (a + foreign producer's non-5 version reply#2; one unit test on `read_reply2` directly). -8. `producer.rs:560` — the quiet wait's `Err(_)` (I/O error, not - EOF) arm; `producer.rs:568` — the BIND reply#2 Domain-peer - fallback (`TargetAddr::Domain(_, port)` → `0.0.0.0:port`) — one +8. `producer.rs:560` (the quiet wait's `Err(_)`, i.e. an I/O error, not + EOF); `producer.rs:568` (the BIND reply#2 Domain-peer + fallback (`TargetAddr::Domain(_, port)` → `0.0.0.0:port`)): one factory returning a Domain peer pins it. -9. `egress.rs:72-74, 226-230, 275-279` — the three `Debug` impl - bodies (render once each); `error.rs:113-115` — +9. `egress.rs:72-74, 226-230, 275-279`: the three `Debug` impl + bodies (render once each), plus `error.rs:113-115`: `SocksEstablishmentReason`'s `Display`. Cosmetic; batch freely. **Explicitly out of scope** (the review's accepted-uncovered 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. 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 - [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 - [x] `AssociateSession::split` + `BindSession::split` tested on a live stream (or the batch-2-door note recorded per arm) @@ -94,7 +94,7 @@ through an artificial shape. ## References - 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) - src/producer.rs `egress_factory_called_once_and_dropped_with_the_stage` (the drop-witness pattern) @@ -127,7 +127,7 @@ through an artificial shape. treatment: `udp_egress_send_failure_ends_the_association_and_ drops_the_egress` (a full ASSOCIATE conversation, one datagram in, 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 `OneReplyEgress`/`RelayEndedEgress`/`ParkedEgress` (stage-shaped 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_pre_reply2` exercise `tokio::io::split` on a live `Socks5Stream` 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` assembles): raw framed bytes pass through both directions on the 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 needed; the tests stand alone. - **The single-arm residue, test by test:** `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), `recv_datagram_mid_batch_later_frame_err_drops_the_pending_oks` (first frame `Ok`, a later one garbage → the pending `Ok`s dropped with the session, the fail-loud-drops-pending semantics pinned), `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 ASSOCIATE mirror), `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), `read_reply2_rejects_a_foreign_producers_version` (a non-5 version reply#2 → `UnsupportedSocksVersion(0x04)`, loud), `bind_quiet_wait_read_error_ends_the_wait_cleanly` (the quiet - wait's `Err(_)` arm driven directly on `bind_quiet_wait_and_serve` - — a read *error* ends the wait `Ok(())` and the accept future + wait's `Err(_)` arm driven directly on `bind_quiet_wait_and_serve`, + where a read *error* ends the wait `Ok(())` and the accept future drops with its owned state, the client-gone contract's error-shaped twin), `bind_domain_peer_falls_back_to_the_ unspecified_address` (a foreign accept handle carrying `TargetAddr::Domain(_, port)` peer → reply#2 carries `0.0.0.0:port`, data plane live), `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), `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_ and_pending` (the ASSOCIATE + BIND session `Debug` bodies), `spent_associate_close_reaps_a_no_op` (the `close`-on-spent arm), and `establisher_records_identity_and_defaults_the_establishment` extended with the `None`-witness arm. - **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 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` parks once (with a wake) then errors on the second poll so both 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 changes); it is covered by the same test that pins the spent-send 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); cosmetic, not chased. - **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 tested. The explicit out-of-scope items stayed out of scope: `rfc1928/client.rs:217-219` remains accepted-uncovered (with its @@ -204,7 +204,7 @@ through an artificial shape. blocks. - **Residual uncovered lines after this task (none new)**: producer .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), rfc1928/client.rs 217-219 + the fork internals (accepted- uncovered per review-001/002), wire.rs/mod.rs/target_addr.rs diff --git a/tasks/implementation/fix-hygiene-residue.md b/tasks/implementation/fix-hygiene-residue.md index 30a2b18..487a10e 100644 --- a/tasks/implementation/fix-hygiene-residue.md +++ b/tasks/implementation/fix-hygiene-residue.md @@ -1,6 +1,6 @@ --- 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 depends_on: [] scope: narrow @@ -12,20 +12,20 @@ level: implementation ## Description 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`): -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` 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 the registry sees no declared failure contract for `channels/socks5/sub`, and the doc describes a contract the spec does not carry. - Fix per the review's lean: **declare the `ErrorDefinition`** — - port alktty's (the first protocol crate's precedent, + Fix per the review's lean: **declare the `ErrorDefinition`**, + porting alktty's (the first protocol crate's precedent, `/workspace/@alkdev/alktty/src/channels.rs:165-208`: the `channel:open_failed` definition with the `{ reason, message }` 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 declaration pin. If the declare-side turns out to hit a 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` pass is crate-family-wide, matching alktunnels, pending") and 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 executions; the block above always sets `current` before the 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 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`). 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 without this note. ## Acceptance Criteria - [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 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 - [x] The `feed` let-else is restructured; zero behavior change (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, §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/`) - alktty precedent: `/workspace/@alkdev/alktty/src/channels.rs:165-208` (the `ErrorDefinition` + schema) and `:790` (the pinning test) - 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 -- **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 the definition on the first try. The schema is alktty's `channel:open_failed` block (`/workspace/@alkdev/alktty/src/channels.rs:165-208`), adapted: the `reason` enum carries all five ADR-049 §3 codes (`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 declared vocabulary is chosen to match `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` text is rewritten to this crate's establishment story: the 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 the no-phantom-channel property. One judgment call worth noting for the review round: the declared enum is *wider* than 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 op's contract shape (any registration of this op, per ADR-049 §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 (`:780-807`), `open_spec_declares_the_open_failed_error_definition` 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 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 `let Some(&mut (declared, ref mut acc))` match has the same 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 header read into the let-else's else arm (`let Some((declared, acc)) = self.current.as_mut() else { - ` on the builder, `bool` on the built producer). The free function `serve_socks5_over` (already gate-explicit) and the pump handler (identity-derived, ADR-006) are untouched. - **D-2 vehicle: a new `RegisterMode::ProducerBuilder`** in `tests/harness.rs` wiring the full builder chain (`.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` + `register_openable_with_establisher` shape as the others, via the 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 producer unit suite (`builder_bind_policy_false_refuses_bind_ 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 one. - **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 wired + policy unwired ⇒ BIND serves (factory ran); accept 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 `run_datagram_stage` (hot-`Ok(None)` = a yielding busy-wait, bounded by the egress's own correctness, accepted-uncovered by @@ -136,7 +136,7 @@ ride the same files ## Summary 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 producer resolves it to a plain `bool`); `Socks5Producer:: 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 (`RegisterMode::ProducerBuilder`) plus one integration test wires 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)` residual (S-1); wire.md §UDP ASSOCIATE carries the inline-send backpressure bound (N-2). Verification: `cargo test` 352 green (125 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 348 baseline), `cargo clippy --all-targets -- -D warnings` clean, `cargo fmt --check` clean, `cargo doc --no-deps` clean, both wasm diff --git a/tasks/implementation/fix-rfc1928-posture.md b/tasks/implementation/fix-rfc1928-posture.md index eac1aed..19b1298 100644 --- a/tasks/implementation/fix-rfc1928-posture.md +++ b/tasks/implementation/fix-rfc1928-posture.md @@ -1,6 +1,6 @@ --- 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 depends_on: [] scope: narrow @@ -11,13 +11,13 @@ level: implementation ## Description -The review-001 keep-or-cut triggers have fired — the producer layer -landed without consuming the anticipated surfaces — and the +The review-001 keep-or-cut triggers have fired (the producer layer +landed without consuming the anticipated surfaces), and the in-stream refusal vocabulary mislabels policy refusals as bugs. One posture pass over both (`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; the seams carry their own error vocabularies; `pump_bidi` replaced `transfer`. Per the review's disposition: @@ -27,22 +27,22 @@ One posture pass over both - `StandardAuthentication` + `allow_no_auth` + `auth_method_enums!`'s generated enum (`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` (`src/rfc1928/server.rs:185-187`; the consumer typestate's `skip_auth` config covers the in-tree need) - `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) **Keep with note** (the hand-port-map posture, ADR-013): - `ConnectError` (constructed nowhere, but the hand-port map's - companion — its reply-code mapping is what a future port - reuses; the module doc already says so) + companion; its reply-code mapping is what a future port + reuses, which the module doc already says so) - `transfer`/`wait_on_tcp` (differential-suite-consumed; their semantics stay pinned) - `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 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 similar enum" line) and the differential suite's comment that 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). -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 wire never sees (the wire got its RFC-faithful reply already; these are log/observability signals only), but the vocabulary lies: the BIND command gate returns `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 - refusal is not a bug; the display literally says `BUG: ...` — - `src/producer.rs:262-264`, `302-304`, `483-485`, `541-543`). + refusal is not a bug, but the display literally says `BUG: ...` at + `src/producer.rs:262-264`, `302-304`, `483-485`, `541-543`).. An operator grepping `BUG:` chases ghosts that are working-as-designed refusals. @@ -70,17 +70,17 @@ One posture pass over both (`SocksServerError::Refused(&'static str)`-shaped) for the in-stream seam refusals and the command gate; use it at the five refusal sites. The wire bytes are untouched (these variants never - reach the wire — the RFC reply went out first). Alternatively - keep `Bug` and rename its display — but the dedicated variant is + reach the wire, since the RFC reply went out first). Alternatively + keep `Bug` and rename its display, but the dedicated variant is the honest shape; pick one and record it. Mind the fork's provenance posture (convention 17): this is an owned-code extension, so extend the module doc's carried-changes note if the variant list grows. 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 -`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 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 enum, `skip_auth_this_is_not_rfc_compliant`, and `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) - [x] The differential suite stays green (the cut machinery was not load-bearing for it; the `:103` comment reworded) - [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 green) - [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) - docs/architecture/decisions/013-targeted-fork.md (the hand-port 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) -- src/rfc1928/LICENSE (the provenance notice — extend, never +- src/rfc1928/LICENSE (the provenance notice; extend, never replace) ## Notes -- **N-4 shape chosen: the dedicated variant** — - `SocksServerError::Refused(&'static str)`, display `"Refused: - {0}"` — not the `Bug` display rename. The review's sketch favored +- **N-4 shape chosen: the dedicated variant**, namely + `SocksServerError::Refused(&'static str)` with display `"Refused: + {0}"`, not the `Bug` display rename. The review's sketch favored the dedicated variant ("the honest shape"), and the vocabulary split is real: `Bug` stays upstream-verbatim (`BUG: {0}`, its "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("egress 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 reply went out before each `Err`). - **The harness's `Bug` site was migrated too** (the task said "leave unless it wants the new variant"): `tests/harness.rs`'s - minimal CONNECT producer returned `Bug("dial refused in-stream")` - — the same working-as-designed refusal shape, so it takes the new + minimal CONNECT producer returned `Bug("dial refused in-stream")`, + the same working-as-designed refusal shape, so it takes the new variant for vocabulary consistency across the log surface. No test asserts that harness error's variant, so nothing else moved. @@ -142,7 +142,7 @@ display/shape if the vocabulary is table-tested elsewhere. fall-through loop in `to_reply_error_mapping_table` (alongside `Bug`), and a new test pins the display string and the 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". - **Provenance extended, not replaced** (convention 17): the `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 `last_updated` line. The bundled `src/rfc1928/LICENSE` notice 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 records that the trigger fired and the disposition is **keep** (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). - **The differential suite's `:103` comment** reworded to the 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 (upstream's "use enums for sets" line → a custom selection is a 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` + `StandardAuthenticationStarted` + `allow_no_auth`, `skip_auth_this_is_not_rfc_compliant`, and `CheckResult`'s -`Option`/`Result` impls are gone from `src/rfc1928/server.rs` — -the producer-replaced machinery, per ADR-013's +`Option`/`Result` impls are gone from `src/rfc1928/server.rs`, since +they were the producer-replaced machinery per ADR-013's no-speculative-surface posture. Kept with notes: `ConnectError` (hand-port companion, `stream.rs` note updated to record the fired trigger + keep disposition), `transfer`/`wait_on_tcp` diff --git a/tasks/implementation/fix-test-helper-consolidation.md b/tasks/implementation/fix-test-helper-consolidation.md index 8e820d4..b46cf6f 100644 --- a/tasks/implementation/fix-test-helper-consolidation.md +++ b/tasks/implementation/fix-test-helper-consolidation.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/fix-coverage-residue] scope: narrow @@ -12,15 +12,15 @@ level: implementation ## Description 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). 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: - `src/producer.rs:954-987` (`QueueEgress`), `1237-1274` (`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 - `src/producer.rs:792-814` (`greet`/`write_request`/`read_reply`) 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` 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 shapes) and the `Witness` egress (carries the drop flag). The 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 shapes) - [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 count (minus any folded duplicates, recorded in the Notes) - [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 `#[cfg(test)] extern crate self as alksocks;`. The `#[path]` 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 `#[path]` rejects non-literal expressions (no `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 the lib test crate the alias makes the same paths resolve. `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 `harness`**: `tests/harness.rs` declares `#[path = "common/mod.rs"] pub mod common;` and re-exports the consumed subset under the names its callers already use (`echo_egress` for `echo_egress_factory`, `udp_frame`/`udp_unframe` for `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 consumer-* suites needed no edits at all. - **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)` adapter since its witness arg is per-test. - 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` + producer's `QueueEgress` (the review's mutex-wrapper-only divergence) + `echo_egress`/`echo_egress_factory` → common's `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 refusing egress factory → common's `refusing_egress` (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). - The raw RFC 1928 client trio: harness `RawRfcClient`'s `greet`/`request`/`read_reply` bodies + producer's @@ -134,24 +134,24 @@ introduce fresh duplicates of harness shapes, fold them here too. names). - The parked accept factory: harness-era producer `parked_accept_factory` + core-integration's `parked_accept` → - common's `parked_accept_factory` (bind addr `127.0.0.1:2121` - — the ECHO_BIND_PORT carrier; core-integration imports it + common's `parked_accept_factory` (bind addr `127.0.0.1:2121`, + the ECHO_BIND_PORT carrier; core-integration imports it aliased as `parked_accept`). - **Deliberately not collapsed** (per the task; verified 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 drop flags), core-integration's `HotNoneEgress` (same-named but the concurrency-load variant), and the refusing accept-factory/accept-future pair (typed per error class; used by the harness and consumer-bind only). Also left: harness's `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; noted for the next touch but out of this task's named scope. - **Zero behavior change, same test count**: 145 lib / 38 associate / 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 new tests were all consumer-side unit shapes with no 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 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` + `extern crate self as alksocks`) for the lib test crate and from `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 accept factory. Left deliberately distinct: the hot-none egress 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 tests folded. Verification: `cargo test` + `--all-features` green, clippy `-D warnings` clean (native + wasm), fmt clean, doc clean, diff --git a/tasks/implementation/local-backend-producer.md b/tasks/implementation/local-backend-producer.md index c1c4eac..f01ceb6 100644 --- a/tasks/implementation/local-backend-producer.md +++ b/tasks/implementation/local-backend-producer.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/local-providers] scope: narrow @@ -12,48 +12,48 @@ level: implementation ## Description 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 (producer.md §Producer-Side Local Backend). This is the shape where the SOCKS5 service rides a real `TcpListener` instead of (or alongside) channels: each accepted TCP connection is handed to the landed -substrate-free entry `Socks5Producer::serve_socks5_over` directly — -the RFC conversation runs on the accepted socket, the seams dial +substrate-free entry `Socks5Producer::serve_socks5_over` directly, so +the RFC conversation runs on the accepted socket while the seams dial locally. Deliverables: -1. **`src/local/` additions** (feature `local`) — the producer-side +1. **`src/local/` additions** (feature `local`), the producer-side listener shape: - 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 builder type), a bind address, and the door-policy knobs (the - idle-first-byte timeout + max-concurrent-sessions cap — the - same two knobs the consumer door carries, ADR-014's + idle-first-byte timeout + max-concurrent-sessions cap, the + same two knobs the consumer door carries, per ADR-014's accept-path defense; an accepted socket is not a channel). - The accept loop: bind the listener, per accepted connection spawn `producer.serve_socks5_over(stream)`; per-connection 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). - **This is a front door for the producer side**: the door surface (NoAuth raw pipe vs RFC 1929 cred-bearing, ADR-005) is the consumer door's code shape. For the producer-side backend 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 - `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 documented follow-up unless a test drives it (record the decision in Notes). - The BIND command gate on this path is the builder's - `bind_policy` (the landed `Socks5ProducerBuilder::bind_policy` - — the substrate-free entry's gate); document that the + `bind_policy` (the landed `Socks5ProducerBuilder::bind_policy`, + i.e. the substrate-free entry's gate); document that the 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 - 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 sockets); BIND through the local backend (the faithful-RFC variant: real reply#1/reply#2 addresses); the idle-first-byte @@ -67,11 +67,11 @@ Deliverables: --check` clean - [ ] Default wasm checks stay green (backend code is `local`-gated) - [ ] 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 - knobs) — pinned by tests -- [ ] Per-connection failure isolation — pinned by test -- [ ] The `bind_policy` gate is honored on this path — pinned by test + knobs), pinned by tests +- [ ] Per-connection failure isolation, pinned by test +- [ ] The `bind_policy` gate is honored on this path, pinned by test ## References @@ -87,7 +87,7 @@ Deliverables: ## Notes - **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 builder + accept-loop object (`serve()` binds, `serve_on(listener)` is the 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 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 - `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` before handing the stream to `serve_socks5_over`. - **Guard shape** (the alktunnels `TunnelListener` teardown posture): `serve`/`serve_on` return a `ProducerDoorGuard`; `close()` ends the accept 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 - 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 - 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 `bind_policy(false)` test (0x02 refusal, CONNECT still serves). - **No protocol-layer code**: the accept loop rides @@ -115,7 +115,7 @@ Deliverables: ## Summary 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 `Socks5Producer::serve_socks5_over` (NoAuth in-stream RFC conversation; `local::tcp_dial()` / `udp_egress()` / `bind_accept()` dial locally). The diff --git a/tasks/implementation/local-providers.md b/tasks/implementation/local-providers.md index 038bb22..fea310d 100644 --- a/tasks/implementation/local-providers.md +++ b/tasks/implementation/local-providers.md @@ -1,6 +1,6 @@ --- 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 depends_on: [] scope: moderate @@ -12,42 +12,42 @@ level: implementation ## Description 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 convenience constructors over the landed seam types (`src/egress.rs`); the protocol layer never sees them. 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 - 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 `[dependencies]`; the `net` feature rides `local` alone (ADR-009). Verify the default graph stays net-free (`cargo tree` spot-check) 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: - `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 into the `TargetHandle` halves (`Box` — `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` faithfully (`ConnectionRefused` for connect-refused, `NetworkUnreachable`/`HostUnreachable` per `io::ErrorKind`, `NotAllowed` never synthesized by this provider). - `udp_egress()` → `EgressFactory`: one real `UdpSocket` per - association (bind `127.0.0.1:0` — the assembly-layer bind - decision, explicit here), per-datagram `send_to`/`recv_from`, no + association (bind `127.0.0.1:0`, the assembly-layer bind + decision, made explicit here), per-datagram `send_to`/`recv_from`, no 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` parks on `sock.readable().await` then `try_recv_from` - (`WouldBlock` → `Ok(None)` spurious-wakeup shape; a real async - park — never a hot `Ok(None)`); replies carry *inner* bytes - (`TargetAddr::to_be_bytes() + data` — the sender's address per - the RFC's per-datagram source semantics; framing-once — the + (`WouldBlock` → the `Ok(None)` spurious-wakeup shape; a real async + park, never a hot `Ok(None)`); replies carry *inner* bytes + (`TargetAddr::to_be_bytes() + data`, the sender's address per + the RFC's per-datagram source semantics; framing-once, so the length prefix is the wire layer's). `send` takes payload only. Egress failures map to `EgressError` (bind failure → `General`; resolve failure → `Unreachable`). @@ -56,14 +56,14 @@ Deliverables: requested target's port honored when non-zero (FTP active mode), port 0 → ephemeral; `local_addr()` is 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 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 reply#2's `BindTargetHandle.peer` (byte-exact). Factory failures → `BindAcceptFactoryError` (bind refused/held port → `Refused` or `Shortage` per the io error; accept-stage failures → `BindAcceptError`). Address policy is the caller's: the 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 (the providers ARE the explicit bind decisions), and the seam contracts each constructor must honor (readiness, ownership, @@ -72,7 +72,7 @@ Deliverables: integration file): real sockets (dev-deps already carry full tokio). Echo TCP server for `tcp_dial` round-trips; a real UDP 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 re-pinned with the real socket egress (park-then-datagram, no spin); refusal mappings (connect-refused → 0x05 through the @@ -84,15 +84,15 @@ Deliverables: - [x] `cargo clippy --all-targets -- -D warnings` clean; `cargo fmt --check` clean - [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 - [x] Default dependency graph has no `tokio/net`/`mio`/`socket2` (`cargo tree` spot-check); `local = ["tokio/net"]` wired - [x] `tcp_dial`/`udp_egress`/`bind_accept` land with the exact producer.md §Wiring names and the landed seam types - [x] The egress readiness contract honored (real async park; no hot - `Ok(None)`) — pinned by test -- [x] The accept future owns its listener end-to-end — pinned by a + `Ok(None)`), pinned by test +- [x] The accept future owns its listener end-to-end, pinned by a drop-witness test (client-gone → listener closed) - [x] Refusal mappings table-tested against the landed error types' `to_reply_error()` @@ -104,7 +104,7 @@ Deliverables: seams), 007 (BIND variant 1), 012 (resolution at the egress) - docs/architecture/producer.md §Wiring, §The Three Seams - 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`) - Landed seams: `src/egress.rs` (the contracts' normative doc comments) @@ -114,7 +114,7 @@ Deliverables: > Filled by the implementation agent (2026-09-25). - `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 wasm-clean subset; the `net` feature rides `local` alone (ADR-009). `cargo tree` spot-checks: the default graph @@ -122,13 +122,13 @@ Deliverables: `tokio/net`/`mio`/`socket2` (inverting `-i mio` prints nothing); with `--features local --target all` the `net` feature appears rooted at `alksocks feature "local"`, dragging `mio` + `socket2` - in through tokio (the dev-dep `full` also appears in dev builds — - pre-existing, test-only, not the feature's doing). + in through tokio (the dev-dep `full` also appears in dev builds, + pre-existing and test-only, not the feature's doing). - `src/local/mod.rs` (feature `local`, one module file + a `tests` submodule per convention 15): - `tcp_dial()` → `DialFn`: `TcpStream::connect` per CONNECT - target; domain targets resolve here (the ssh `-D` semantic — - ADR-012), iterating the resolved address set (the `-D` + target; domain targets resolve here (the ssh `-D` semantic, + with ADR-012 in play), iterating the resolved address set (the `-D` connect-multi-address posture: first success wins, the last failure is the reported error); `into_split` halves boxed as `Send + Sync + Unpin` (compiles, pinned by the round-trip test). @@ -138,16 +138,16 @@ Deliverables: resolution failure → `HostUnreachable`, everything else → `General`; `NotAllowed` never synthesized. - `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 `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 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` (`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; framing-once); `send` takes payload only. Bind failure → `General`; resolve failure → `Unreachable`. @@ -155,7 +155,7 @@ Deliverables: `TcpListener` per BIND session (ADR-007 variant 1); the requested port honored when non-zero, port 0 → ephemeral; `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); `accept()`'s peer is reply#2's `BindTargetHandle.peer` (byte-exact). Factory failures → `BindAcceptFactoryError` @@ -163,7 +163,7 @@ Deliverables: `AddrNotAvailable` → `Refused`, else `General`); accept-stage failures → `BindAcceptError` (`General`). Address 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`). - Module doc names ADR-008/009, the never-binds posture (the providers ARE the explicit bind decisions), and the four seam @@ -173,14 +173,14 @@ Deliverables: `tcp_dial`/`udp_egress`/`bind_accept`/`LocalEgress` re-exports; the crate doc's "not yet present" paragraph updated (the composed 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 mappings, the UDP echo round-trips (inner bytes + sender address, in-order, empty payload), the park-then-datagram readiness probe, the BIND accept (real reply#1/reply#2, requested-port honored, held-port refusal, the drop-closes-the-listener witness)) + 6 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 through the producer's datagram stage with the real egress, the readiness contract re-pinned through the stage (park-then-datagram, @@ -202,17 +202,17 @@ Deliverables: > Filled on completion (2026-09-25). 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 (`src/egress.rs`): `tcp_dial()` (real `TcpStream` per CONNECT, domain resolution at the provider, faithful `SocksDialError` mapping), `udp_egress()` (one real `UdpSocket` per association bound `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)` (a real `TcpListener` per BIND session, requested port honored, the 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 net/mio/socket2-free (`cargo tree` spot-checked both ways), and the wasm checks stay green. 18 new tests (12 unit + 6 integration) pin: diff --git a/tasks/implementation/review-assembly-layer.md b/tasks/implementation/review-assembly-layer.md index 81a93a9..5c51dff 100644 --- a/tasks/implementation/review-assembly-layer.md +++ b/tasks/implementation/review-assembly-layer.md @@ -1,6 +1,6 @@ --- id: review-assembly-layer -name: Review — the assembly layer (the batch-2 gate) +name: Review (the assembly layer, the batch-2 gate) status: completed depends_on: [implementation/assembly-integration-suite] 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: 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 the default crate's dependency graph carries no `tokio/net` (`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 codec appears only at the door boundary; FRAG ≠ 0 refused. 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 resurrected. 5. **Convention compliance** (AGENTS.md): no `unwrap`/`expect`/ panics outside tests; `thiserror` surfaces; no comments in code except doc comments + pinned non-obvious-correctness notes; 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. 6. **Feature-gate hygiene** (ADR-009): `local` brings only `tokio/net` (+ `socket2` if justified); `composed` brings only alktunnels; the default graph unchanged; the wasm checks green 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 assembly behaviors (the composed egress's LRU eviction + closed- queue posture, the cred-bearing door's re-origination, the bind-first correlation), diff the crate's behavior against the - POC crates' findings — the POCs are the empirical record; drift - is a finding (except where an ADR supersedes the POC's shape — - the bind-first graduation is the known one). + POC crates' findings; the POCs are the empirical record, so drift + is a finding (except where an ADR supersedes the POC's shape, of + which the bind-first graduation is the known one). 8. **Docs sync**: the specs match what landed (the integration task's reconciliation verified; any residue is a finding). ## Summary 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 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: @@ -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 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`/ `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` (`cargo tree -e normal -f "{p} [{f}]"`: tokio rides the wasm-clean subset `bytes,io-util,macros,rt,sync,time`; `net` appears only under `--features local`, which also brings `mio`+`socket2`; `composed` 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 -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 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). -**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` (`WouldBlock` → the spurious `Ok(None)`, never hot); the composed egress parks on the shared queue's `recv().await`, and its 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 landed egress actually detects every-relay-ended and returns `Err`, 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 `Send + Unpin`-only alktunnels halves in `SyncHalves`/`OwnedWrite` (`Mutex` 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 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 (swept). *The accept future owns its resources end-to-end* in both 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 `addr + data` inner bytes; the composed egress returns inner bytes with its own oversize guard mirroring the wire bound; the tunnel-side framing is the tunnel codec's (`frame_datagram`), never the RFC 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 -(`copy_bidirectional`, zero translation — pinned by +(`copy_bidirectional`, zero translation, pinned by `door-tcp.rs`'s raw-byte probes). The cred-bearing door terminates 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 never-pipes-raw-post-auth property is pinned by the re-origination probe (the vanilla client's command bytes are consumed door-side; the in-band conversation is fresh). The token is the mapped 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 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 dropping the datagram, the association stays healthy (pinned). The 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 -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 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 -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 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 carries the forwarded op's real peer`), and the establishment bound covers only the open-op round trip (the listener open is the 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 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 -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 never imported from the protocol modules (swept: zero `crate::local`/`crate::door`/`crate::composed` references in the protocol layer); producer/consumer vocabulary holds (RFC-facing "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 wasm checks clean for default AND `--features composed` (the 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 guarantee, verified). `local` brings only `tokio/net` (+ `mio`/ `socket2` transitively); `composed` brings only alktunnels; the 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.** - *The composed egress's LRU + closed-queue posture*: the lazy destination→tunnel table, the LRU eviction-before-insert loop, the touch-on-access ordering, the session-inside-the-entry placement, and the relay task's shape are all line-faithful to POC #2's `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 held its own `Sender`); the crate replaces it with the `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, validated by the integration suite's vanilla-client round trips). - *The bind-first correlation*: the token→oneshot registry shape - with the forwarded handler owning the data-plane pump (R-02 held — - the handler's inline `pump_bidi` await tracks the plane) matches + with the forwarded handler owning the data-plane pump (R-02 held, + as the handler's inline `pump_bidi` await tracks the plane) matches the alktunnels 0.2.0 forwarded-op surface; the three-party half-close deadlock note (the bridge completes on EITHER direction's EOF) is the pinned non-obvious-correctness comment 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 gates, dependencies), consumer.md (the front doors, the TokenClient door-only contract, the BIND take-guard), producer.md §Wiring, and README.md Current State all match the tree. Residues fixed: 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 directly via `serve_socks5_over` (no channel in the path; rewritten, 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); `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. ## 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 in-band decode (`recv_inband`, `src/door.rs`) created a fresh `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 batched beyond the first in one chunk were **silently dropped**. This is exactly POC #2's first-cut bug (ADR-003: "reader state 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 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 `DatagramReader` + batched-datagram pending queue, the `AssociateSession` shape) threaded through the channel→socket pump, plus the fail-loud mid-frame EOF (never a silent partial datagram). Pinned by three unit tests on the bridge (batched - frames queue-not-drop; partial-frame state persists across calls - — the test fails against a per-call reader; EOF mid-frame errors + frames queue-not-drop; partial-frame state persists across calls, + 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 (`batched_in_band_replies_both_reach_the_vanilla_client`). - **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`); `TokenClient::wrap`'s call-connection-taken panic → `TokenClientError::CallConnectionTaken` (a client is one calling - surface — the door-only contract). Test call sites updated - (`.expect("the door builds")` etc. — test-side, allowed); the new + surface, the door-only contract). Test call sites updated + (`.expect("the door builds")` etc., which is test-side and allowed); the new error types are re-exported (`lib.rs`). - **R-3 (doc residue, fixed)**: producer.md's producer-side local-backend section described the landed shape as a raw channel 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; no channel pipe). Reconciled, including the `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 `batched_in_band_replies...` test does not deterministically 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 reader). The end-to-end test still guards the regression class through the real stack; the unit tests carry the determinism. diff --git a/tasks/implementation/review-fix-round-002.md b/tasks/implementation/review-fix-round-002.md index 58386cf..89927c9 100644 --- a/tasks/implementation/review-fix-round-002.md +++ b/tasks/implementation/review-fix-round-002.md @@ -1,6 +1,6 @@ --- 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 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 @@ -11,14 +11,14 @@ level: review ## 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` providers, front doors, `composed` providers). Batch 2 consumes the 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 entry, the N-4 vocabulary) would propagate into every door task. 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-*`). 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 extended-not-replaced (convention 17). 5. **D-3 resolved**: the `ErrorDefinition` is declared (or the doc - honestly reworded with the reason recorded) — doc-vs-code drift - gone either way. + honestly reworded with the reason recorded), so doc-vs-code drift + is gone either way. 6. **N-8 coverage**: `cargo llvm-cov` re-run; the named lines covered or recorded as accepted-uncovered with reasons; no new 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 collapsed; the constructor prologue is single-sourced. 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, all-features, clippy, fmt, doc, both wasm checks, 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 - [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 - [x] `cargo llvm-cov` re-run and attributed (no new load-bearing gaps) @@ -74,14 +74,14 @@ when C-2's ADR is accepted and no major finding remains. `cargo publish --dry-run --allow-dirty` - [x] Convention compliance re-swept (no unwrap/expect/panics 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 ## References - docs/reviews/002-protocol-core-implementation-review.md (the 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) - tasks/implementation/review-protocol-core.md (the inline-review 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 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)` (consumer.rs:793-795). The returned `BindDataPlane` **owns** 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 - 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 re-read). Pinned by `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 `reply1_twice_is_a_view_and_the_reader_yields_one_plane` (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, - `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 (consumer.rs:574-579, 762-765). 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()`, - `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 permitted" (the presence reading is only the backward-compat default). Pinned by @@ -126,7 +126,7 @@ when C-2's ADR is accepted and no major finding remains. ASSOCIATE still serve) and `builder_default_bind_policy_follows_accept_presence` (the 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`) stayed green untouched. 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 (`auth_method_enums!`, `StandardAuthentication` + `allow_no_auth`, `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 stream.rs note records the fired trigger + the keep disposition; `transfer`/`wait_on_tcp` differential-suite-consumed; `Bug` 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 237/269/309/502/560) plus the harness's minimal producer (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 post-fork clause needed no change. 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 exactly `SocksEstablishmentReason`'s parse table), doc-vs-code drift gone. Pinned by `open_spec_declares_the_open_failed_error_definition` (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. 6. **N-8 coverage re-run and attributed.** `cargo llvm-cov` on the 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 fallback, the `Debug`/`Display` renders). The residual uncovered 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 + internals (the review-001/002 accepted-uncovered posture), `rfc1928/mod.rs` 136-141 / `target_addr.rs` 132/151/180 (the fork's pre-existing residue), plus test-module panic-guard 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). No new load-bearing gaps introduced. 7. **N-5/N-6 consolidation verified.** The shared module (tests/common/mod.rs, 314 lines) compiles into both crates via the crate-root `#[cfg(test)] #[path]` mount + the `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 from `src/`. The deliberately-distinct shapes stayed test-local (`HotNoneEgress`/`PureHotNoneEgress`/the drop-witness egresses); 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). 8. **C-2 decided.** ADR-014 exists (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 + the local-backend section + the decision table). 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/`). ### 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 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 "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` - — the unnumbered inline review); "review 002" now unambiguously + batch-1 gate review (`tasks/implementation/review-protocol-core.md`, + the unnumbered inline review); "review 002" now unambiguously 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 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 impact; the review doc's remaining in-body `R-1`/`R-2` mentions 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 publish --dry-run --allow-dirty`: completes. - `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%, producer 95.70%, wire 96.58%, rfc1928 96.53–99.39% (the accepted-uncovered fork blocks ride client.rs's 96.53%). - **Convention compliance re-swept**: every `unwrap`/`expect`/ `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 comments + pinned correctness notes only); no `cfg(feature)` 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 `ErrorDefinition`) are the shapes batch 2's door tasks consume. 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). ## Summary diff --git a/tasks/implementation/review-protocol-core.md b/tasks/implementation/review-protocol-core.md index 3a58d58..5119308 100644 --- a/tasks/implementation/review-protocol-core.md +++ b/tasks/implementation/review-protocol-core.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/core-integration-suite] scope: narrow @@ -13,8 +13,8 @@ level: review Review checkpoint before batch 2 (the assembly layer: `local` providers, front doors, `composed` providers). Everything in batch 2 -consumes the seams and the session types, so a core defect — a seam -contract drift, a teardown hole, a wasm regression — would propagate +consumes the seams and the session types, so a core defect (a seam +contract drift, a teardown hole, a wasm regression) would propagate into every downstream task. This review is the gate between the core group and the assembly layer, and the point where batch 2 gets decomposed (its task shapes may adjust to what the core actually @@ -23,8 +23,8 @@ landed). Review against the architecture, in this order: 1. **Seam contracts** (ADR-004, producer.md §The Three Seams): the - readiness contract (`next_reply` `Pending` when nothing is ready — - the contract test exists and pins it), ownership-rides-the-handle + readiness contract (`next_reply` `Pending` when nothing is ready, + with the contract test in place pinning it), ownership-rides-the-handle (no `_` bindings of owning values; handles own their payloads structurally), the accept future owning its resources end-to-end (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 registry), the command gate (`socks5:bind` at BIND dispatch), 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): - `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-at-birth paths (the third-witness pattern held). 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 behaviors (the readiness contract, the quiet-wait two-exit 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. 8. **Docs sync**: the specs match what landed (the integration task's reconciliation verified; any residue is a finding). ## Notes -> Filled by the review agent — see the Summary and the Findings. +> Filled by the review agent: see the Summary and the Findings. ## 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 seam-contract, wire-fidelity, ACL, teardown, wasm, or POC-fidelity 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 on the trait (`egress.rs:174-185`), table-tested, and pinned behaviorally twice (the producer unit test's mutex-guarded hot egress + the integration suite's hot-`Ok(None)` under concurrent load). Ownership rides the handle structurally (`TargetHandle`/ `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 wait's `select!`-pinned drop is the client-gone teardown, pinned by 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 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 bound (`frame`/`frame_inner` reject > 65535 with the exact per-form maxima pinned: 65528 v4 / 65516 v6 / 65276 domain), F-2 at payload 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; post-reply garbage is refused structurally in all three commands (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` (asserted in the integration suite + the raw probes), BIND's 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 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 (`socks5_open_spec`); the command gate resolves `socks5:bind` from 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 registry's `FORBIDDEN` at the open gate); the mapping tables (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 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 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 followed by fresh sessions of every command). No teardown-at-birth 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 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 after R-2 (swept: `src/` non-test regions are clean; the forked 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 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 wasm32-unknown-unknown -- -D warnings`, from scratch); no `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 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 `select!`-driven shape (channel reads ∥ reply drains, `Ok(None)` tolerated as a spurious wakeup); the POC's LocalEgress/ ComposedEgress both park properly (`readable()` / queue `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* - 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 (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. - *Quiet-wait two-exit semantics*: the landed `select!` is 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 the factory's `bind_addr`/peer byte-exactly on the provider variant and the sentinels on variant 3. - *Split-datagram reader persistence*: `AssociateSession::recv_ datagram`'s reader + pending queue persist across calls (pinned by - the 2-byte-prefix chunk test and the consumer-associate suite) — - matches ADR-003's re-read of the POC finding. + the 2-byte-prefix chunk test and the consumer-associate suite), + 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 §Consequences, producer.md §Scope Gating, and the landed spec/tests (see the Notes). Residues found and fixed: `src/egress.rs`'s module 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" -(same class — reworded); producer.md §Wiring's sketch showed -`producer.register(&mut channel_core)` — the landed signature takes +(same class, reworded); producer.md §Wiring's sketch showed +`producer.register(&mut channel_core)`, while the landed signature takes `(&ChannelCore, &OperationRegistry, AuthContext, Option)` and also carries the establisher (corrected). The rest of the specs (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 - [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 byte-checked against wire.md/bast.md - [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 - [x] Both wasm checks green (from clean), no net-shaped feature, 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 - [x] Docs sync: specs match the tree; residues fixed in the same change @@ -209,11 +209,11 @@ State, lib.rs crate docs) match the tree. ### 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 while its futures yield at least one poll boundary. A *zero-yield* - egress — `next_reply` = a pure `async { Ok(None) }`, no await at - all — wins every `select!` poll forever: the channel branch and + egress (`next_reply` = a pure `async { Ok(None) }`, no await at + all) wins every `select!` poll forever: the channel branch and every other task on a current-thread scheduler starve (empirically reproduced: the echo never arrives; the multi-thread runtime 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 `yield_now()`s before re-parking (`src/producer.rs` `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 prose and `egress.rs`'s trait doc stay as written (the contract is on the egress; the stage now additionally defends itself). - **R-2 (convention, fixed in the review change)**: `Socks5Channel 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 session's inner is gone only after close/shutdown/Drop, and no public API constructs a spent session), but convention 2's rule is the rule: the shape is now `Result` / `Result` 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 nothing; the large-Err/unit-Err lints shaped the choice), pinned 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 `src/producer.rs` establisher doc carried task-time phrasings ("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 `bind_quiet_wait_is_not_under_the_establishment_bound` test doc 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 behavior is the harness's pin by construction). Reworded to say 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 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 - `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- 001) carries the open gate unconditionally; a bind-only identity 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 `identity_matrix_bind_only_fails_closed_at_the_open_gate`), and a 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 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 - well-placed — this review treats the reconciliation as verified + well-placed; this review treats the reconciliation as verified rather than open. - The self-reported verification claims reproduce (the landed suite 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 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 included), `cargo test --all-features` green, `cargo clippy --all-targets -- -D warnings` clean, `cargo fmt --check` clean, diff --git a/tasks/implementation/review-vendor-extraction.md b/tasks/implementation/review-vendor-extraction.md index ee21834..0582184 100644 --- a/tasks/implementation/review-vendor-extraction.md +++ b/tasks/implementation/review-vendor-extraction.md @@ -1,6 +1,6 @@ --- id: review-vendor-extraction -name: Review — the vendor extraction before dependent layers +name: Review (the vendor extraction before dependent layers) status: completed depends_on: [implementation/vendor-differential-tests] scope: narrow @@ -13,8 +13,8 @@ level: review Review checkpoint before any layer builds on the vendor. Everything else in the crate (params, wire, producer, consumer, doors, backends) -consumes the vendored typestate/codecs, so extraction errors — a -behavior drift vs upstream, a missed cut, a wasm regression — would +consumes the vendored typestate/codecs, so extraction errors (a +behavior drift vs upstream, a missed cut, a wasm regression) would propagate into every downstream task. This review is the gate between 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 "What is Extracted" row is present; every "What is NOT Extracted" 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 carried changes (`new_reply` publication; the BIND surface being 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 in-tree; the MIT notice present and referenced. 6. **Docs sync**: `vendor.md`'s extraction table and cut table match - what landed (if the implementer discovered a boundary correction — - e.g. a transitive dependency the cut table missed — the doc was + what landed (if the implementer discovered a boundary correction, + e.g. a transitive dependency the cut table missed, the doc was updated in the same change, not silently diverged). ## 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 `/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 absent (swept by name for `Socks5Datagram`/`connect*`/`run_*`/ `udp_bind_random_port`/`handle_udp*`/`transfer_udp`/`Socks5Server`/ `Socks5Socket`/`Incoming`/`DnsResolveHelper`/`Config`/ `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 documented fidelity keep). Every hunk of all five file diffs (`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 -- -D warnings` both clean; no `net`-shaped 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). **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 `vendor`); `#![forbid(unsafe_code)]` present. `Cargo.toml`'s two 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. **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 `0x00`–`0x08`, `0x09`–`0xFF` unassigned), the method-selection preference order (§3 + upstream's outer-loop contract), the UDP 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 -the FRAG parser/caller split (§7: MUST drop FRAG≠0 — refusal is the -caller's, passthrough the parser's). All pinned bytes correct. The +the FRAG parser/caller split (§7: MUST drop FRAG≠0, with the refusal being the +caller's and passthrough the parser's). All pinned bytes correct. The differential suite's 32 tests pass; its upstream file+line citations were spot-checked against the reference checkout and are accurate. @@ -125,7 +125,7 @@ Config-Default/`write_all` notes; `last_updated` bumped). ## Acceptance Criteria - [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 - [x] Both wasm checks pass (`cargo check` and `cargo clippy --target wasm32-unknown-unknown -- -D warnings`) @@ -140,9 +140,9 @@ Config-Default/`write_all` notes; `last_updated` bumped). ### 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 - 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 reply byte from the wire into it (`src/vendor/client.rs:340`), and 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 task's file, whose criterion enumerated variants but not panic 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 in `vendor.md`'s adjustment list (same change, check-6 discipline). 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 carries inline comments (AGENTS.md convention 1 says no comments in 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 until the features populate). Accepted; no fix demanded. - **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. ## References @@ -186,7 +186,7 @@ Config-Default/`write_all` notes; `last_updated` bumped). green), and their boundary corrections were honest and documented (`mod.rs` vocabulary placement; `SocksServerError` early landing). 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 (each task verified its own files); the review's whole-vendor sweep is what caught it. Task summaries left as-is (historical record); diff --git a/tasks/implementation/vendor-client-session.md b/tasks/implementation/vendor-client-session.md index b9ddaa0..d5c812a 100644 --- a/tasks/implementation/vendor-client-session.md +++ b/tasks/implementation/vendor-client-session.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/vendor-wire-vocabulary] scope: narrow @@ -11,8 +11,8 @@ level: implementation ## Description -Extract the client session — the consumer half's stream-session base -(ADR-002; POC #1's fit) — into `src/vendor/client.rs`: the +Extract the client session, the consumer half's stream-session base +(ADR-002; POC #1's fit) into `src/vendor/client.rs`: the `Socks5Stream::use_stream` path plus its `AsyncRead`/`AsyncWrite` impls, generic over the backing stream. The consumer layer (a later 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`, `read_request_reply`, `get_socket`/`get_socket_ref`/`get_socket_mut`. 2. The `AsyncRead`/`AsyncWrite` impls for `Socks5Stream` (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 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 `skip_auth` (consumed by `use_stream`). Keep `skip_auth`; drop `connect_timeout` and its setter (its only consumer is cut). If dropping it leaves `Config` a single-field struct, keep it anyway (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. **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 `consumer.rs` per ADR-003's handle swap). - The `Socks5Stream` 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 channels `BiStream`, not a TCP dial). **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): replace `.context("...")?` with plain `?` where the error variant already carries the meaning, and with the typed variants (`SocksError::Io`, `AuthMethodUnacceptable`, ...) where upstream - used `anyhow::Error::msg(...).into()` (upstream `client.rs:285` — - the missing-target-addr case) or `SocksError::Other(anyhow!(...))` - (upstream `client.rs:528` — but that site is inside the cut + used `anyhow::Error::msg(...).into()` (upstream `client.rs:285`, the + missing-target-addr case) or `SocksError::Other(anyhow!(...))` + (upstream `client.rs:528`, but that site is inside the cut `Socks5Datagram`, so it goes with the cut). No behavior change to the wire: the same bytes are read/written; only the error typing changes. - 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)`): convention 2 forbids panics in library code. Replace with a typed 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, not a panic. - `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 @@ -87,9 +87,9 @@ shape (vendor.md's extraction table). - [x] `Socks5Stream::use_stream` over an in-memory duplex completes: no-auth handshake, password handshake (both accept and reject 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 - 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 wire expectations uncorrelated from the server implementation; 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: `Socks5Stream::use_stream`) - 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 (`Socks5Stream::request` target construction; the `AuthenticationRejected` unit-variant note) @@ -118,13 +118,13 @@ shape (vendor.md's extraction table). - **Verbatim-code lint posture**: no module-level clippy allows were 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 posture note stays in the module doc for the re-vendor diff. - **Clippy `unused_io_amount` fix (the one non-verbatim line)**: upstream's `request_header` writes with `self.socket.write(&packet [..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 conversation anyway, so `write_all` is the faithful translation (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. - **`anyhow::Error::msg("target addr should be present").into()`** (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 variant; the message text is upstream's, kept as the variant's `#[error]` string). This is the task's "typed variants where upstream used `anyhow::Error::msg(...).into()`" adjustment; the variant is wire-inert (error surface only). - **`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` → `AuthenticationRejected("missing user pass")`. In-tree the `Some(None)` and `None` arms collapse to a single `AuthenticationRejected("Authentication rejected, missing user - pass")` return — the same message as upstream's `None` arm, an - error return instead of a panic (convention 2). The state is + pass")` return (the same message as upstream's `None` arm, an + error return instead of a panic, per convention 2). The state is reachable only by misuse (`auth = Some(AuthenticationMethod::None)` while the server picks password). - **`Config` pruned to `skip_auth`**: `connect_timeout` + its setter 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 the manual impl (upstream's manual `Default` only existed to spell 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 destructured `version` fed only a dropped `debug!`. In-tree the dropped-log rename would have discarded it entirely; instead it is - checked against `SOCKS5_VERSION` (→ `UnsupportedSocksVersion`) — - the same check every other reply read in this file performs; the + checked against `SOCKS5_VERSION` (→ `UnsupportedSocksVersion`), the + same check every other reply read in this file performs, since the alternative (an `_version` binding) silently accepts a v4 reply on the sub-negotiation. Byte-level behavior on a compliant server is unchanged. @@ -173,8 +173,8 @@ shape (vendor.md's extraction table). only via formatting; nothing renamed (the field is written on the previous line, read in `request_header`). - **Tests are first-party** (upstream's client test module rides the - cut net region — it tests `connect` against real listeners): the - test module here is written fresh against the crate's test layout — + cut net region, testing `connect` against real listeners): the + test module here is written fresh against the crate's test layout: raw-byte mock servers over tokio duplex streams, pinned byte sequences written directly (uncorrelated from the vendored server typestate, per the task). Coverage: no-auth handshake + CONNECT @@ -187,14 +187,14 @@ shape (vendor.md's extraction table). `AuthMethodUnacceptable`), version mismatch (both greeting directions), missing target addr (typed `MissingTargetAddr`), 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 → `SocksError::ReplyError` with the code round-tripped), domain >255 (→ `ExceededMaxDomainLen(256)`), `tokio::io::copy` through the session (the `AsyncRead`/`AsyncWrite` impls), and the socket accessors (`get_socket_ref`/`get_socket_mut`/`get_socket`). - **`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 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 (`Socks5Datagram` struct + impl, the `Socks5Stream` convenience impl with `connect`/`connect_with_password`/ -`connect_raw`, `Config::connect_timeout` + setter) were not copied — -verified by sweep (only doc-comment provenance mentions remain). +`connect_raw`, `Config::connect_timeout` + setter) were not copied +(verified by sweep; only doc-comment provenance mentions remain). Extraction-time adjustments applied: `anyhow` dropped (the `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 `Socks5Datagram`), `log` calls dropped (unused bindings renamed), `use_password_auth`'s `unreachable!()` replaced with an error return diff --git a/tasks/implementation/vendor-crate-scaffolding.md b/tasks/implementation/vendor-crate-scaffolding.md index a3b273d..a6dff59 100644 --- a/tasks/implementation/vendor-crate-scaffolding.md +++ b/tasks/implementation/vendor-crate-scaffolding.md @@ -1,6 +1,6 @@ --- 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 depends_on: [] scope: narrow @@ -12,7 +12,7 @@ level: implementation ## Description 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 protocol-only with zero kernel-net code (ADR-009 §Amendment, ADR-011 §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"`, `rust-version = "1.88"`, `license = "MIT OR Apache-2.0"`, 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 features are populated by their modules' later tasks; `composed` will add the alktunnels dep, `local` will add `tokio/net` + `socket2`). - `[dependencies]`: `tokio = { version = "1", default-features = false, 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"`. - **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. - `[dev-dependencies]`: `tokio = { version = "1", features = ["full", "test-util", "macros"] }` (tests are native-only; they are not part of the wasm-clean surface). 2. **`src/lib.rs`**: `#![forbid(unsafe_code)]`, brief crate docs - (what the crate is, pointing at `docs/architecture/overview.md` — - doc comments are fine and expected), `pub mod vendor;`. + (what the crate is, pointing at `docs/architecture/overview.md` + since doc comments are fine and expected), `pub mod vendor;`. 3. **`src/vendor/` skeleton** preserving upstream file names so per-file diffs against the reference checkout stay mechanical (the 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), per ADR-011; the extraction cut + carried changes are recorded in `docs/architecture/vendor.md`; submodule declarations for `server`, `client`, `util` (stub files, filled by the extraction tasks). - `src/vendor/server.rs`, `src/vendor/client.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 license text plus attribution (fast-socks5 v1.0.0, 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). ## Acceptance Criteria @@ -64,7 +64,7 @@ Deliverables: - [x] `cargo clippy --all-targets -- -D warnings` clean - [x] `cargo fmt --check` clean - [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.toml` has no `alkcall`, no `anyhow`, no `log`, no `async-trait`, no `tokio-stream`, no `socket2` in `[dependencies]` @@ -75,7 +75,7 @@ Deliverables: ## References - 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) - docs/architecture/decisions/009-feature-gates.md (the feature layout) - docs/architecture/overview.md §Dependencies (the tokio subset) @@ -86,39 +86,39 @@ Deliverables: ## Notes -- **Cargo.toml** follows the alktunnels posture (the closer sibling — - same tokio wasm-clean subset incl. `time`, same dev-dep tokio +- **Cargo.toml** follows the alktunnels posture (the closer sibling with + the same tokio wasm-clean subset incl. `time`, same dev-dep tokio shape): `exclude` mirrors alktunnels (docs/research/ + tasks/ + sdd_process.md); keywords/categories/description point at the SOCKS5 story; repository is the git.alk.dev remote. `[features]` - declares `default`/`local`/`composed` empty (ADR-009 §Amendment — - populated by their modules' tasks); `[dependencies]` is exactly the - task's trio (tokio wasm-clean subset, tracing, thiserror — no - alkcall, no net-shaped anything). A `[lib]` section pins the crate + declares `default`/`local`/`composed` empty (ADR-009 §Amendment, + to be populated by their modules' tasks); `[dependencies]` is exactly the + task's trio (tokio wasm-clean subset, tracing, thiserror, i.e. no + alkcall and no net-shaped anything). A `[lib]` section pins the crate 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 overview.md link), `pub mod vendor;`. Nothing else: later tasks add their own modules + re-exports. - **src/vendor/** skeleton preserves upstream v1.0.0 file names for mechanical per-file diffs: `mod.rs` (provenance module doc + 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 task and (for the util files) the cut-region note; `util/mod.rs` pre-declares `stream` + `target_addr` exactly as upstream does. `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`) under a provenance header (v1.0.0, github.com/dizda/fast-socks5, ADR-011 pointer, vendor.md pointer); referenced from the `mod.rs` module doc via an intra-doc link. The upstream license has no - "MIT License" body-text title line — it opens with "MIT License" - + copyright; the notice keeps that text verbatim after the header. + "MIT License" body-text title line; it opens with "MIT License" + + copyright, and the notice keeps that text verbatim after the header. - Verification ran clean: `cargo test` (0 tests, compiles + runs), clippy `--all-targets -- -D warnings`, `cargo fmt --check`, `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- subset + tracing + thiserror only). diff --git a/tasks/implementation/vendor-differential-tests.md b/tasks/implementation/vendor-differential-tests.md index b4b98ee..6604976 100644 --- a/tasks/implementation/vendor-differential-tests.md +++ b/tasks/implementation/vendor-differential-tests.md @@ -1,6 +1,6 @@ --- 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 depends_on: [implementation/vendor-server-typestate, implementation/vendor-client-session] scope: moderate @@ -11,18 +11,18 @@ level: implementation ## Description -Build the crate's RFC edge-case test suite for the extracted vendor — -the differential-testing posture vendor.md pins: "the crate's test +Build the crate's RFC edge-case test suite for the extracted vendor, +per the differential-testing posture vendor.md pins: "the crate's test suite covers the edge cases the extracted code must keep passing; when in doubt, diff behavior against the reference checkout." The per-task unit tests (wire vocabulary, typestate, client session) cover the happy paths; this task covers the RFC edge cases and 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 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 test suite (tests are native-only via dev-deps; the crate itself stays 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 → 0x01/0xff reply then error). 2. **Command-read edge cases**: unknown command byte (0x04+ → - `UnknownCommand` → 0x07 reply), reserved-byte tolerance (RSV ≠ 0 — - upstream reads it without checking; pin the upstream behavior), + `UnknownCommand` → 0x07 reply), reserved-byte tolerance (RSV ≠ 0; + upstream reads it without checking, so pin that upstream behavior), address-type edge cases (ATYP 0x02/0x05+ → `IncorrectAddressType` → 0x08 reply), domain-length boundary (255-byte domain OK, 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 request encoding (ATYP=3, len-prefixed), the UDP-ASSOCIATE 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). 5. **UDP codec edge cases**: `parse_udp_request` with garbage RSV → - `GarbageInReserved`, FRAG ≠ 0 passthrough (the parser returns frag; - the *caller* refuses — pin the parser's contract), + `GarbageInReserved`, FRAG ≠ 0 passthrough (the parser returns frag, + and the *caller* refuses; pin the parser's contract), `new_udp_header`/`parse_udp_request` round-trip for v4/v6/domain. 6. **`transfer`/`wait_on_tcp` semantics**: `transfer` pumps both 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 extraction deliberately diverges (the pruned error variants, 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. ## Acceptance Criteria - [x] The six coverage groups above each have at least one 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 default-feature build) - [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) - docs/architecture/decisions/002-protocol-base.md (faithful 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 `src/server.rs` test module are the upstream coverage to mirror where extractable) @@ -109,14 +109,14 @@ introduced by this task. ## Notes - **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. - **Differential method followed**: each non-obvious expectation's doc comment names the upstream file + line it pins (e.g. `src/server.rs:903-914` for method-order preference, `src/client.rs:277-285` for the UDP-ASSOCIATE zero fallback, `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 only reachable through private methods (`request_header`'s `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 of those tests already live in `src/vendor/client.rs`'s in-module 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 whole pipe's write side, so the `transfer` half-close-propagation group is driven with drops (full EOF) rather than `shutdown()`-per-direction; the test's doc comment records why. - **First-party-only divergence check**: the only in-tree contracts the tests pin beyond upstream behavior are the two recorded - carried changes/adjustments (`new_reply` published — group 3; the - typed `MissingTargetAddr` replacing upstream's `anyhow::Error` — - group 4 note). No new divergence introduced. + carried changes/adjustments (`new_reply` published, in group 3; the + typed `MissingTargetAddr` replacing upstream's `anyhow::Error` in + group 4's note). No new divergence introduced. ## Summary Built the vendor's differential RFC edge-case suite per vendor.md §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 mid-handshake typed error, password sub-protocol empty username/password + the 0x01/0xff-then-error reject path); command diff --git a/tasks/implementation/vendor-server-typestate.md b/tasks/implementation/vendor-server-typestate.md index 5bdd54d..917fd3c 100644 --- a/tasks/implementation/vendor-server-typestate.md +++ b/tasks/implementation/vendor-server-typestate.md @@ -1,6 +1,6 @@ --- id: implementation/vendor-server-typestate -name: Vendor — the server typestate (Socks5ServerProtocol + carried changes) +name: Vendor (the server typestate, Socks5ServerProtocol + carried changes) status: completed depends_on: [implementation/vendor-wire-vocabulary] scope: narrow @@ -11,8 +11,8 @@ level: implementation ## Description -Extract the server typestate — fast-socks5's explicit API, the -producer's conversation driver (ADR-002) — into +Extract the server typestate, meaning fast-socks5's explicit API and the +producer's conversation driver (ADR-002), into `src/vendor/server.rs`, and apply the two carried changes that touch this file (BIND's reply surface + the `new_reply` publication). This 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 server-side wire error vocabulary; keep `UnexpectedUdpControlGarbage` - — the ASSOCIATE quiet-wait semantics consume it). -2. `ErrorContext` trait + its two impls (`err_when`) verbatim — + since the ASSOCIATE quiet-wait semantics consume it). +2. `ErrorContext` trait + its two impls (`err_when`) verbatim, i.e. the typestate's internal error-context idiom, used pervasively in the extracted region. 3. The typestate core, verbatim: @@ -39,54 +39,56 @@ task group) drives it directly over a channels `BiStream`. `password_states` module, `PasswordAuthenticationStarted` alias). - `auth_method_enums!` macro + `StandardAuthentication` (keep the 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). - `negotiate_auth`, `read_command`, `reply_success`, `reply_error`. - - `transfer` + `wait_on_tcp` (the typestate's own two-pump seam — - vendor.md's extraction table; `transfer` is the interception-point - shape, `wait_on_tcp` is the ASSOCIATE control-stream reader). + - `transfer` + `wait_on_tcp` (the typestate's own two-pump seam; + vendor.md's extraction table says `transfer` is the + interception-point shape and `wait_on_tcp` is the ASSOCIATE + control-stream reader). - `try_notify!` macro (internal to the extracted region; same 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 public as a `reply(code, addr)`-shaped constructor. Upstream: `fn new_reply(error: &ReplyError, sock_addr: SocketAddr) -> Vec` (private, `server.rs:1380`). In-tree: `pub fn new_reply(...)` - (or `pub fn reply(code: &ReplyError, addr: SocketAddr) -> Vec` - — either name; pin one and use it consistently) so the BIND + (or `pub fn reply(code: &ReplyError, addr: SocketAddr) -> Vec`; + either name; pin one and use it consistently) so the BIND 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` 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): - `run_tcp_proxy`, `run_udp_proxy`/`run_udp_proxy_custom`, `udp_bind_random_port`, `handle_udp_*`/`transfer_udp` (all - kernel-net; the producer drives the typestate directly — POC #2's - shape — and the local backends are first-party tokio code, ADR-008). + kernel-net; the producer drives the typestate directly, being POC #2's + shape, and the local backends are first-party tokio code, ADR-008). - 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). - `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` + `Authentication` trait + impls (`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 `#[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): - Drop `anyhow::Context` imports and `log` calls (`trace!`/`debug!`/ - `error!`/`warn!` in the extracted region — drop or re-point at - `tracing`; the typestate's traces are per-field debug logs, drop - them; keep behavior identical). + `error!`/`warn!` in the extracted region; drop or re-point at + `tracing`. The typestate's traces are per-field debug logs, so drop + them, keeping behavior identical). - `reply_error`'s `"0.0.0.0:0".parse().unwrap()` (upstream `server.rs:945`): replace the `unwrap` with a `const` parsed at 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 translation). - `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). - Keep `#[allow(async_fn_in_trait)]` only where upstream had it and 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). **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 `TCPBind`, `reply_success(sock_addr)` is reply#1's carrier, and 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`/ `authenticate_callback` anywhere under `src/vendor/` (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`, `async-trait` anywhere under `src/vendor/` - [x] `new_reply` (or `reply`) is `pub` and produces byte-identical 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) - [x] `reply_error` contains no `unwrap`/`expect` (convention 2) - [x] `accept_no_auth` over an in-memory duplex completes a full negotiation + `read_command` + `reply_success`/`reply_error` 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), success reply form, error reply form - [x] `cargo clippy --all-targets -- -D warnings` and @@ -152,9 +154,9 @@ surface BIND needs is present and public. ## Notes - **Verbatim-code lint posture**: upstream style trips two - newer-clippy lints in the copied region — - `clippy::wrong_self_convention` (`AuthMethod::new(self, inner)` - — the typestate's method-initializer idiom) and + newer-clippy lints in the copied region: + `clippy::wrong_self_convention` (`AuthMethod::new(self, inner)`, + the typestate's method-initializer idiom) and `clippy::doc_lazy_continuation` (upstream doc-comment style). `wrong_self_convention` is allowed at module level with rationale (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 branch logged the secondary reply failure via `log::error!`; 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 matches the dropped log's only role). - **`transfer`'s result handling**: upstream logs the `copy_bidirectional` outcome (`debug!`/`error!`); in-tree the result is explicitly discarded (`let _ =`). The function's 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. - **`reply_error`'s unwrap replacement**: upstream parses `"0.0.0.0:0"` at runtime (infallible but `unwrap`); 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 BND.ADDR as `0.0.0.0:0`). - **`auth_method_enums!` scoping**: converted from `#[macro_export]` to `macro_rules!` + `pub(crate) use` (same adjustment as `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 - 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 client-session task, which may consume it. - **Tests are first-party**: upstream's `server.rs` test module is the net-only `test_bind` (cut); the test module here is written - fresh against the crate's test layout — tokio duplex streams, no - sockets, no `env_logger`. The round-trip suite covers the + fresh against the crate's test layout (tokio duplex streams, no + sockets, no `env_logger`). The round-trip suite covers the acceptance list: version mismatch, 0xff method refusal (with the 2-byte wire reply pinned), unknown command (0x07 reply + the `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) + the full reply-code table (0x00–0x08, TtlExpired/ConnectionTimeout 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 `SocksServerError::UnknownCommand` → `CommandNotSupported` (pinned 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 per vendor.md's extraction table; if dead-code warnings had 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 @@ -233,13 +235,13 @@ alias), the `auth_method_enums!` macro (de-exported to `negotiate_auth`/`read_command`/`reply_success`/`reply_error`, `transfer`/`wait_on_tcp`, the `try_notify!` macro, `ErrorContext` + 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 `Socks5Server`/`Incoming`/`Socks5Socket`, `Config` + `Authentication` + the example auth impls + `authenticate_callback`, `DnsResolveHelper`, the `run_*` kernel-net handlers + `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 tests (v4 10-byte / v6 22-byte forms + the full reply-code table). Extraction-time adjustments applied: `log` calls dropped (with the diff --git a/tasks/implementation/vendor-wire-vocabulary.md b/tasks/implementation/vendor-wire-vocabulary.md index cedd5f6..8da38e4 100644 --- a/tasks/implementation/vendor-wire-vocabulary.md +++ b/tasks/implementation/vendor-wire-vocabulary.md @@ -1,6 +1,6 @@ --- id: implementation/vendor-wire-vocabulary -name: Vendor — the wire vocabulary (consts, commands, codecs, errors, macros) +name: Vendor (the wire vocabulary, i.e. consts, commands, codecs, errors, macros) status: completed depends_on: [implementation/vendor-crate-scaffolding] scope: narrow @@ -12,7 +12,7 @@ level: implementation ## Description Extract the pure wire vocabulary from fast-socks5 v1.0.0 into -`src/vendor/` — the RFC 1928 codecs and error enums that everything +`src/vendor/`, holding the RFC 1928 codecs and error enums that everything else (typestate, client sessions, producer/consumer layers) consumes. This is the "codecs" row of vendor.md's extraction table plus the `lib.rs` non-net region. Copy from `/workspace/fast-socks5` (read-only @@ -22,32 +22,32 @@ result must be wasm-clean by construction (no `tokio::net`, no **What is extracted** (upstream file → in-tree target): -1. **`src/lib.rs` → `src/vendor/lib.rs`** (new file — upstream's +1. **`src/lib.rs` → `src/vendor/lib.rs`** (new file: upstream's `lib.rs` content minus the cut regions): - `consts` module verbatim (all `SOCKS5_*` constants). - - `Socks5Command` enum + `as_u8`/`from_u8` (make both `pub` — the + - `Socks5Command` enum + `as_u8`/`from_u8` (make both `pub`: the producer's command dispatch and the consumer's request writer both need them; upstream keeps them private with `#[allow(dead_code)]`). - `AuthenticationMethod` enum + impls (consumed by the client session's `use_stream` path; keep as-is). - `ReplyError` enum + `as_u8`/`from_u8` verbatim. - `UdpHeaderError` enum, `new_udp_header`, `parse_udp_request` - verbatim (the ASSOCIATE datagram codec — vendor.md's codecs row). + verbatim (the ASSOCIATE datagram codec per vendor.md's codecs row). - `Result` type alias. - **NOT extracted**: the net-heavy test module (upstream `lib.rs` - lines 345–520 — it binds real sockets; the crate's own test task + lines 345–520, which binds real sockets; the crate's own test task covers these edge cases), the `socks4` module declaration. 2. **`src/util/target_addr.rs` → `src/vendor/util/target_addr.rs`**: - `AddrError`, `TargetAddr` (+ `is_ip`/`is_domain`/`to_be_bytes`/ `into_string_and_port`), `ToSocketAddrs` impl, `Display` impl, - `ToTargetAddr` trait + all impls, `Addr` enum, `read_address` — - verbatim. - - **NOT extracted**: the `resolve_dns` method (POC #4's gate map — - it is the one net-touching member; `tokio::net::lookup_host` is + `ToTargetAddr` trait + all impls, `Addr` enum, `read_address`, + all verbatim. + - **NOT extracted**: the `resolve_dns` method (POC #4's gate map, + since it is the one net-touching member; `tokio::net::lookup_host` is not in the wasm-clean graph). Delete the method; `AddrError:: DNSResolutionFailed`/`NoDNSRecords` go with it (see adjustments). - - Drop the `debug!` log calls (upstream `log` → dropped or re-pointed - at `tracing`; these are per-octet debug traces — drop them). + - Drop the `debug!` log calls (upstream `log`, to be dropped or re-pointed + at `tracing`; these are per-octet debug traces, so drop them). 3. **`src/util/stream.rs` → `src/vendor/util/stream.rs`**: - `read_exact!` and `ready!` macros verbatim. **Adjustment**: they are `#[macro_export]` upstream (crate-root exported, which would @@ -56,11 +56,11 @@ result must be wasm-clean by construction (no `tokio::net`, no stay internal (the wrapper's own code gets its own macros; the vendor's are implementation detail). - `ConnectError` enum + `to_reply_error` verbatim (the reply-code - mapping vocabulary — consumed by the dial seam's error mapping). + mapping vocabulary consumed by the dial seam's error mapping). - **NOT extracted**: `tcp_connect`/`tcp_connect_with_timeout` (the - `tokio::net::TcpStream` dialers — POC #4's gate map; the crate's + `tokio::net::TcpStream` dialers per POC #4's gate map; the crate's local backend is first-party tokio code, ADR-008). -4. **`SocksError` (in `src/vendor/lib.rs`)** — the error vocabulary, +4. **`SocksError` (in `src/vendor/lib.rs`)**, meaning the error vocabulary, pruned to what the extracted region + the crate's error surface actually consumes (vendor.md: "the vendored `SocksError`/ `SocksServerError` stay the wire-level vocabulary"): @@ -69,16 +69,16 @@ result must be wasm-clean by construction (no `tokio::net`, no `ServerError(#[from] SocksServerError)`, `UdpHeaderError(#[from] UdpHeaderError)`, `AddrError(#[from] AddrError)`, `ReplyError(#[from] ReplyError)`. - - Drop: `Redaction`, `InvalidHeader` (dead upstream — no consumer in + - Drop: `Redaction`, `InvalidHeader` (dead upstream, with no consumer in the extracted region), `ConnectError(#[from] ConnectError)` (its only producer was the cut `tcp_connect*`; `ConnectError` - itself stays — the dial seam maps it), `ReplySocks4Error` (socks4 + itself stays, as the dial seam maps it), `ReplySocks4Error` (socks4 is cut), `ArgumentInputError` (no consumer), `Other(#[from] - anyhow::Error)` (anyhow is dropped — vendor.md's adjustments). + anyhow::Error)` (anyhow is dropped per vendor.md's adjustments). - Note: `AuthenticationRejected(String)` on `SocksError` is the client-side variant (client.rs uses it with a message); `SocksServerError::AuthenticationRejected` (unit) is the - server-side one — both stay, each in its own enum. + server-side one; both stay, each in its own enum. **Extraction-time adjustments** (mechanical, per ADR-011 §Amendment + vendor.md): drop `anyhow` wrapping, drop `log` calls, prune @@ -91,7 +91,7 @@ behavior is byte-identical to upstream v1.0.0. - [x] `src/vendor/lib.rs`, `src/vendor/util/{mod,stream,target_addr}.rs` exist with the inventory above; `cargo test` compiles (**boundary note**: the vocabulary lives in `src/vendor/mod.rs`, - not a second root file `src/vendor/lib.rs` — the completed + not a second root file `src/vendor/lib.rs`, because the completed scaffolding task pinned the vendor root as `mod.rs` with upstream file names preserved; a second root file would be an E0758 conflict. Upstream's `lib.rs` content minus the cut regions is @@ -113,7 +113,7 @@ behavior is byte-identical to upstream v1.0.0. `cargo fmt --check` clean - [x] Unit tests for the codecs pass: `to_be_bytes`/`read_address` round-trip (v4/v6/domain), `new_udp_header`/`parse_udp_request` - round-trip, `ReplyError::as_u8`/`from_u8` mapping table — ported + round-trip, `ReplyError::as_u8`/`from_u8` mapping table, ported from upstream's non-net test regions where extractable (vendor.md's "non-net test modules" row), adjusted to the crate's test layout (no `env_logger`, no real sockets) @@ -138,14 +138,14 @@ behavior is byte-identical to upstream v1.0.0. vendor root as `src/vendor/mod.rs` (skeleton with `mod` + submodule declarations already committed, 57b107b). A second root file (`lib.rs` alongside `mod.rs`) is an E0758 conflict, so the - vocabulary landed in `mod.rs` — upstream's `lib.rs` content minus + vocabulary landed in `mod.rs` as upstream's `lib.rs` content minus the cut regions, appended after the scaffolding module doc. The review task's file-by-file diff vs upstream is unaffected (the upstream `lib.rs` region maps to `mod.rs`; noted here for the reviewer). - **`SocksServerError` lands early**: `SocksError` keeps `ServerError(#[from] server::SocksServerError)` per the task's - keep-list, but `server.rs` was a stub — a `#[from]` needs the type + keep-list, but `server.rs` was a stub and a `#[from]` needs the type now. Upstream defines `SocksServerError` at the top of `server.rs`; it landed there verbatim (enum + `to_reply_error`, including `UnexpectedUdpControlGarbage`) as the first item of the @@ -168,10 +168,10 @@ behavior is byte-identical to upstream v1.0.0. - **`ready!` macro**: upstream's server typestate consumes it (the `transfer`/`wait_on_tcp` region); nothing in the vocabulary region does. It rides with `#[allow(unused_macros)]`/`#[allow(unused_imports)]` - until the typestate lands — kept verbatim now so the util diff + until the typestate lands, and it is kept verbatim now so the util diff stays complete. - **Tests**: the vocabulary region's unit tests are first-party - (upstream's `lib.rs` test module is the cut net-heavy region — + (upstream's `lib.rs` test module is the cut net-heavy region, so none of its tests are extractable); the codec tests here follow the crate's test layout (tokio duplex for `read_address`, plain slice parsing for `parse_udp_request`, no `env_logger`, no @@ -182,7 +182,7 @@ behavior is byte-identical to upstream v1.0.0. compare via `as_u8()`/`matches!` rather than adding derives. - **The `ToSocketAddrs` impl's error string** still names `TargetAddr::resolve_dns()` (upstream text preserved verbatim) even - though the method is cut — noted in the module doc; a future + though the method is cut, and note it in the module doc; a future consumer hitting that path gets the explicit-resolution message exactly as upstream produced it. @@ -195,13 +195,13 @@ the pruned `SocksError` (no `anyhow`/`Redaction`/`InvalidHeader`/ `ConnectError`-from/`ReplySocks4Error`/`ArgumentInputError`), the `Result` alias, `ReplyError` codec, `UdpHeaderError` + `new_udp_header`/`parse_udp_request`) into `src/vendor/mod.rs` (the -scaffolding-pinned root; the task's `lib.rs` target name corrected — -see Notes), `AddrError`/`TargetAddr`/`ToTargetAddr`/`Addr`/ +scaffolding-pinned root; the task's `lib.rs` target name was corrected +(see Notes), `AddrError`/`TargetAddr`/`ToTargetAddr`/`Addr`/ `read_address` minus `resolve_dns` into `util/target_addr.rs`, and the `read_exact!`/`ready!` macros (de-exported to `pub(crate) use`) + `ConnectError` + `to_reply_error` into `util/stream.rs`. `SocksServerError` (enum + `to_reply_error`) landed verbatim in -`server.rs` early — `SocksError`'s `#[from]` needs it before the +`server.rs` early, since `SocksError`'s `#[from]` needs it before the typestate task lands. 16 first-party unit tests cover the codecs (v4/v6/domain round-trips, UDP header/parse incl. frag/payload/garbage RSV/unknown ATYP, reply-code mapping table, command mapping, diff --git a/tests/assembly-integration.rs b/tests/assembly-integration.rs index b2f5d4e..bc2e757 100644 --- a/tests/assembly-integration.rs +++ b/tests/assembly-integration.rs @@ -1,4 +1,4 @@ -//! The cross-cutting assembly integration suite — the whole assembly +//! The cross-cutting assembly integration suite: the whole assembly //! layer's behavior pinned in one place (the batch-2 closing task; the //! architecture-sync check's test half). //! @@ -8,23 +8,23 @@ //! //! - **The full `-D` story end to end**: a vanilla tun2proxy-shaped //! client → the cred-bearing TCP door → the channels-native session -//! → the producer with `local` providers → real echo targets — +//! → the producer with `local` providers → real echo targets: //! CONNECT + UDP (the RFC-header'd datagram through the UDP door) + //! BIND (the vanilla BIND conversation through the door) all through //! one door process against one producer registration (the //! one-registration-three-commands regression, now through the //! doors). //! - **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 table, //! BIND through the bind-first listener (the ADR-008 composed //! variant set, all three commands). //! - **Mixed assembly**: one producer registration serving //! 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). //! - **The teardown matrix, assembly edition**: door-client disconnect -//! tears the association/channel down (no leaks — the channel count +//! tears the association/channel down (no leaks: the channel count //! witnesses); the idle bounds fire; fresh sessions succeed //! immediately after each teardown (no connection poisoning). #![cfg(all(feature = "local", feature = "composed"))] @@ -170,7 +170,7 @@ async fn vanilla_no_auth_connect(door: SocketAddr, target: SocketAddr) -> (TcpSt (stream, head[1], addr) } -/// An RFC-header'd datagram (the vanilla client's sendto form — the +/// An RFC-header'd datagram (the vanilla client's sendto form, the /// door boundary's codec, hand-rolled per the RFC wire form). fn rfc_datagram(target: SocketAddr, payload: &[u8]) -> Vec { let SocketAddr::V4(v4) = target else { @@ -268,7 +268,7 @@ async fn vanilla_with_creds(door: SocketAddr) -> TcpStream { /// The composed topology: the SOCKS5 producer serves the direct op /// (CONNECT + ASSOCIATE's egress) AND the bind-first op (BIND's -/// listener) on one connection — the ADR-008 composed variant set. +/// listener) on one connection: the ADR-008 composed variant set. /// The direct-op dial is a real TCP/UDP-capable stand-in: a TCP dial /// dials the real echo target (CONNECT rides it); a UDP dial yields a /// framed echo pipe (ASSOCIATE's destinations echo datagrams). @@ -394,7 +394,7 @@ async fn wire_composed_suite() -> ComposedSuite { // The SOCKS5 producer: the composed accept seam (BIND) + the // composed direct-op egress (ASSOCIATE) + a real TCP dial - // (CONNECT — the direct-op dial's TCP arm shares the real target; + // (CONNECT: the direct-op dial's TCP arm shares the real target; // the CONNECT dial here is the crate's own local dial over the // SOCKS5 seam, distinct from the tunnel dial). let factory = bindfirst_accept(topo.opener.clone(), registry, COMPOSED_RESOURCE); @@ -448,7 +448,7 @@ async fn wire_composed_suite() -> ComposedSuite { /// /// - CONNECT: the RFC 1929 door conversation → the CONNECT request → /// the real TCP echo target round-trips. -/// - BIND: the door's BIND conversation (its own RFC session — the +/// - BIND: the door's BIND conversation (its own RFC session, the /// vanilla client's one-command-per-connection shape) → the app /// server dials the advertised address → reply#2's real peer → the /// data plane. @@ -458,7 +458,7 @@ async fn wire_composed_suite() -> ComposedSuite { /// All three sessions are live simultaneously mid-life of each other; /// each teardown ends only its own session. The two doors ride two /// channels connections to the same producer registration (the -/// assembly story: one registration, many connections — the cred- +/// assembly story: one registration, many connections. The cred- /// bearing door's `TokenClient` takes its client's call connection, /// the door-only contract, so the UDP door's association rides its /// own). @@ -472,7 +472,7 @@ async fn full_dash_d_story_one_registration_three_commands_through_the_doors() { // bearing TCP door rides one client (its TokenClient takes the // call connection); the UDP door's association rides a second // connection to the same registration (the UDP relay is a - // separate datagram plane by construction — one relay socket = one + // separate datagram plane by construction: one relay socket = one // association). let tcp_dial = alksocks::local::tcp_dial(); let tcp_topo = wire_consumer_side_with_provider( @@ -498,7 +498,7 @@ async fn full_dash_d_story_one_registration_three_commands_through_the_doors() { .await; // The cred-bearing TCP door (the CONNECT + BIND shape) + the UDP - // door (the datagram plane) — one door process per plane, the + // door (the datagram plane): one door process per plane, the // assembly wiring consumer.md documents. let tcp_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let door_addr = tcp_listener.local_addr().expect("door addr"); @@ -563,7 +563,7 @@ async fn full_dash_d_story_one_registration_three_commands_through_the_doors() { // --- BIND through the door (live, mid-life of the CONNECT) ------ // Its own door connection (the vanilla one-command-per-connection - // shape — the door's RFC half reads one command per conversation). + // shape: the door's RFC half reads one command per conversation). let mut bind_stream = timeout(BOUND, vanilla_with_creds(door_addr)) .await .expect("the second cred conversation"); @@ -762,7 +762,7 @@ async fn door_client_disconnect_tears_the_association_down_no_leaks() { ); // The door client vanishes: the idle bound (the constructor knob) - // is the door's teardown trigger for a silent relay — with the + // is the door's teardown trigger for a silent relay: with the // socket gone, no datagrams arrive, the bound fires, the // association ends (association lifetime = channel lifetime, // ADR-003), and the door's task returns. @@ -815,8 +815,8 @@ async fn door_client_disconnect_tears_the_association_down_no_leaks() { /// The composed story end to end (the ADR-008 composed variant set, /// all three commands): the SOCKS5 producer serves -/// `composed::direct_egress` (ASSOCIATE — the lazy destination→tunnel -/// table) + `composed::bindfirst_accept` (BIND — the bind-first +/// `composed::direct_egress` (ASSOCIATE, the lazy destination→tunnel +/// table) + `composed::bindfirst_accept` (BIND, the bind-first /// listener) + a real TCP dial (CONNECT). The channels-native /// sessions drive all three against the one registration; the /// direct-op dial count witnesses the table's lazy shape. @@ -824,7 +824,7 @@ async fn door_client_disconnect_tears_the_association_down_no_leaks() { async fn composed_story_all_three_commands_on_one_registration() { let suite = wire_composed_suite().await; - // CONNECT through the dial seam (a real TCP echo target — + // CONNECT through the dial seam (a real TCP echo target, // dialed by the producer's local dial). let tcp_target = echo_tcp_server().await; let mut session = timeout( @@ -912,7 +912,7 @@ async fn composed_story_all_three_commands_on_one_registration() { assert_eq!(&bbuf, b"composed-bind"); // All three sessions were live simultaneously; the teardowns end - // only their own sessions (mux/demux isolation) — exercised by + // only their own sessions (mux/demux isolation), exercised by // the interleaving above. Drop + close. drop(read); drop(write); @@ -926,7 +926,7 @@ async fn composed_story_all_three_commands_on_one_registration() { /// producer registration serving channels-native sessions AND door /// clients AND composed seams concurrently. The composed suite's /// producer gains a door-shaped second consumer topology over the same -/// registration — the channels-native sessions and the door clients +/// registration. The channels-native sessions and the door clients /// interleave on the one registration (both shapes, one connection). #[tokio::test] async fn mixed_assembly_channels_native_and_door_clients_concurrent() { @@ -967,7 +967,7 @@ async fn mixed_assembly_channels_native_and_door_clients_concurrent() { // The door fronts (the vanilla shape) on their own connections to // the same registration (the cred-bearing door's TokenClient takes - // its client's call connection — the door-only contract; the UDP + // its client's call connection: the door-only contract; the UDP // door's association rides a second connection). let tcp_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let door_addr = tcp_listener.local_addr().expect("door addr"); @@ -1151,7 +1151,7 @@ async fn mixed_assembly_channels_native_and_door_clients_concurrent() { /// The mixed-composed variant of the mixed-assembly pin: channels- /// native sessions AND composed seams AND door clients on one -/// registration — the composed suite's producer + a door consumer +/// registration: the composed suite's producer + a door consumer /// topology, both shapes live on one connection (roles-not-sides: the /// producer serves every shape through its seams; the connection /// direction is independent of the service direction). @@ -1254,7 +1254,7 @@ async fn mixed_assembly_composed_seams_beside_channels_native_sessions() { fresh.close().await; // The handler-exit teardowns reap asynchronously (the open - // wrapper's post-handler teardown) — settle past the race, then + // wrapper's post-handler teardown); settle past the race, then // probe (the composed suites' shape). for _ in 0..50 { tokio::time::sleep(Duration::from_millis(100)).await; @@ -1264,7 +1264,7 @@ async fn mixed_assembly_composed_seams_beside_channels_native_sessions() { } } // No channel leaks (the composed suites' probe; the producer side - // may retain the adopted forwarded entries — the documented + // may retain the adopted forwarded entries: the documented // concession). harness::assert_composed_channels(&suite.topo, &[0], &[]).await; } @@ -1274,7 +1274,7 @@ async fn mixed_assembly_composed_seams_beside_channels_native_sessions() { /// own front door): a vanilla NoAuth client drives CONNECT + BIND + /// ASSOCIATE through one `ProducerDoor` against one producer /// registration (the `local` provider set), all three live -/// simultaneously — one accepted connection per command (the RFC +/// simultaneously, one accepted connection per command (the RFC /// conversation is one command per accepted socket, the vanilla /// server's shape). #[tokio::test] @@ -1303,7 +1303,7 @@ async fn producer_door_one_registration_three_commands() { .expect("connect round trip"); assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED); - // BIND (live, mid-life of the CONNECT) — its own door connection. + // BIND (live, mid-life of the CONNECT): its own door connection. let mut bind_stream = TcpStream::connect(door_addr).await.expect("door connect"); bind_stream .write_all(&[0x05, 0x01, 0x00]) @@ -1356,9 +1356,9 @@ async fn producer_door_one_registration_three_commands() { "reply#2 carries the real peer" ); - // ASSOCIATE (live, mid-life of both) — its own door connection: - // the sentinel reply + the in-band framed datagram through the - // real egress (the producer door path speaks the in-band wire — + // ASSOCIATE (live, mid-life of both): its own door connection. + // It gets the sentinel reply + the in-band framed datagram through + // the real egress (the producer door path speaks the in-band wire; // the ASSOCIATE datagram plane has no door-side RFC codec here; // the vanilla client is wrapper-aware on this path). let mut assoc_stream = TcpStream::connect(door_addr).await.expect("door connect"); @@ -1429,7 +1429,7 @@ async fn producer_door_one_registration_three_commands() { assert_eq!(&inner[addr_len..], b"prod-udp", "the datagram echoed"); // The teardown: the client drops; the door stays serving (the - // loop continues — the per-accept isolation); a fresh session of + // loop continues, the per-accept isolation); a fresh session of // every command succeeds (fresh CONNECT here; the door is // healthy). drop(connect_stream); @@ -1446,7 +1446,7 @@ async fn producer_door_one_registration_three_commands() { /// The `-D` story's vanilla-client-fidelity pin (tun2proxy-shaped): a /// domain-form CONNECT through the cred-bearing door resolves at the -/// producer's seam (the ssh `-D` semantic, ADR-012) — `localhost` is +/// producer's seam (the ssh `-D` semantic, ADR-012); `localhost` is /// reachable as the echo target. #[tokio::test] async fn dash_d_domain_targets_resolve_at_the_producer_seam() { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index f0dadf0..271217a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,8 +1,8 @@ -//! Shared test helpers — the harness shapes single-sourced for both +//! Shared test helpers: the harness shapes single-sourced for both //! the `src/` unit suites and the integration suite (review 002 N-5; //! the standard `mod common` pattern). The lib tests include this //! file via `#[path = "../tests/common/mod.rs"]` (unit tests in `src/` -//! cannot import `tests/harness.rs` — separate crates); the +//! cannot import `tests/harness.rs`, since the crates are separate); the //! integration suite reaches it through `harness`'s re-exports. //! //! Single-sourced here (the byte-near-identical shapes): the echo @@ -37,9 +37,9 @@ use tokio::sync::Mutex; // --- The dial seam stand-ins ------------------------------------------------- /// An echo dial closure that records every dialed target (the -/// interception-point probe: domain form arrives unresolved — -/// ADR-012; IP-string forms arrive as parsed IP targets). One duplex -/// per dial — the "target" echoes bytes until EOF. The producer-side +/// interception-point probe: domain form arrives unresolved +/// (ADR-012); IP-string forms arrive as parsed IP targets). One duplex +/// per dial; the "target" echoes bytes until EOF. The producer-side /// pump gets split `DuplexStream` halves; a spawned task echoes the /// other end. No sockets, no `local` feature. pub fn echo_dial_witnessed(targets: Arc>>) -> DialFn { @@ -77,7 +77,7 @@ pub fn echo_dial() -> DialFn { } /// A dial that always refuses with the given [`SocksDialError`] -/// variant (the refusal-mapping tests' dial — the refusal rides +/// variant (the refusal-mapping tests' dial; the refusal rides /// in-stream as the mapped RFC reply code). The variant is /// reconstructed per call (the seam errors are not `Clone`). pub fn refusing_dial(err: SocksDialError) -> DialFn { @@ -96,9 +96,9 @@ pub fn refusing_dial(err: SocksDialError) -> DialFn { // --- The egress seam stand-ins ------------------------------------------------ /// The queue egress behind [`echo_egress_factory`]: `send` queues the -/// inner bytes for echo (`addr + data` — the egress speaks inner +/// inner bytes for echo (`addr + data`; the egress speaks inner /// bytes, no length prefix); `next_reply` parks on the queue -/// (`Pending` until a datagram arrives — the readiness contract, +/// (`Pending` until a datagram arrives, which is the readiness contract, /// never an instantly-ready `Ok(None)`). The receiver rides a mutex /// so `&mut self` methods can lock it (the shape both the harness's /// and the producer's inline copies had converged on). @@ -123,7 +123,7 @@ impl Egress for QueueEgress { fn next_reply(&mut self) -> BoxFuture<'_, Result, EgressError>> { Box::pin(async move { // Real async park: `Pending` until a datagram arrives (the - // readiness contract — never an instantly-ready `Ok(None)`, + // readiness contract; never an instantly-ready `Ok(None)`, // which would starve a biased `select!` into livelock). match self.outbound.lock().await.recv().await { Some(inner) => Ok(Some(inner)), @@ -149,7 +149,7 @@ pub fn echo_egress_factory() -> EgressFactory { } /// A doubled-echo egress factory: every datagram echoes back **twice -/// back-to-back** (two frames queued as one reply burst — the +/// back-to-back** (two frames queued as one reply burst; the /// producer's frame writer puts both length-prefixed frames on the /// channel; the consumer's read may batch them into one chunk). The /// reader-state probe for the front doors' bridge (review 003 R-1): @@ -217,9 +217,9 @@ pub fn refusing_egress(err: EgressError) -> EgressFactory { // --- The accept seam stand-ins ------------------------------------------------- /// A parked accept factory: the accept future never resolves (the -/// deterministic quiet-wait probe — the client-gone exit is the only +/// deterministic quiet-wait probe: the client-gone exit is the only /// end this session can take). The advertised bind address matches -/// the harness's `ECHO_BIND_PORT` (2121 — the reply#1 carrier only). +/// the harness's `ECHO_BIND_PORT` (2121, the reply#1 carrier only). pub fn parked_accept_factory() -> BindAcceptFactory { Arc::new(move |_target: TargetAddr| { Box::pin(async move { @@ -233,7 +233,7 @@ pub fn parked_accept_factory() -> BindAcceptFactory { // --- The raw RFC 1928 client trio ---------------------------------------------- -/// Greeting (`05 01 00` — version 5, one method, no-auth) → the +/// Greeting (`05 01 00`: version 5, one method, no-auth) → the /// method-selection read. Returns the selected method byte; asserts /// the method-selection reply's version byte is 5. pub async fn raw_greet(stream: &mut S) -> Result @@ -271,7 +271,7 @@ where } /// Read one reply (the 4-byte head + the BND.ADDR per its ATYP). -/// Returns `(reply_code, atyp, addr_bytes)` — the address bytes as +/// Returns `(reply_code, atyp, addr_bytes)`: the address bytes as /// the RFC form carries them (v4/v6: addr + port; domain: len + /// name + port). pub async fn raw_read_reply(stream: &mut S) -> Result<(u8, u8, Vec), std::io::Error> @@ -346,7 +346,7 @@ where // --- The datagram frame helpers ------------------------------------------------- /// Frame one datagram the way the in-band wire carries it -/// (`[len: u16 BE][addr + data]` — the wire codec's shape). +/// (`[len: u16 BE][addr + data]`, the wire codec's shape). pub fn datagram_frame(addr: &TargetAddr, payload: &[u8]) -> Vec { alksocks::wire::frame(addr, payload) .expect("frame") diff --git a/tests/composed-accept.rs b/tests/composed-accept.rs index 23d161b..b02cc94 100644 --- a/tests/composed-accept.rs +++ b/tests/composed-accept.rs @@ -1,15 +1,15 @@ -//! The composed accept integration tests (ADR-007 variant 2 — the +//! The composed accept integration tests (ADR-007 variant 2, the //! BIND accept whose listener lives on an alktunnels 0.2.0 bind-first //! listen resource, end to end over channels): the graduation's //! vanilla fidelity (reply#1 = the real `bound`; reply#2 = the -//! forwarded op's real `peer` — no stand-ins), the raw pass-through +//! forwarded op's real `peer`; no stand-ins), the raw pass-through //! data plane, the client-gone teardown (drop-witness), two concurrent //! BIND sessions' distinct tokens, and the per-accept isolation. //! //! The topology: the opener (the SOCKS5 consumer side) dials the //! transport; the tunnel producer serves BOTH the alktunnels bind-first //! open op and the SOCKS5 open op on one registry. The forwarded op -//! serves on the opener side (post-hoc `register_forwarded_serving` — +//! serves on the opener side (post-hoc `register_forwarded_serving`, //! the correlation registry the composed accept factories ride). #![cfg(feature = "composed")] @@ -54,12 +54,12 @@ async fn wire_suite_with_identity(identity: alkcall::core::auth::Identity) -> Su resource_registry, composed_test_bind(), composed_queue_loop(queue.clone()), - None, // the SOCKS5 openable registers post-hoc, below — the composed factory needs the opener's client + None, // the SOCKS5 openable registers post-hoc, below; the composed factory needs the opener's client identity, ) .await; // The correlation registry rides the opener's serving registry - // (the forwarded op's serving half — the provider's opener half). + // (the forwarded op's serving half, the provider's opener half). let registry = register_forwarded_serving( &alkcall::channels::operations::ChannelCore::new( topo.opener.manager().clone(), @@ -70,7 +70,7 @@ async fn wire_suite_with_identity(identity: alkcall::core::auth::Identity) -> Su ) .expect("register forwarded openable"); // The SOCKS5 openable registers post-hoc (W2's shape) on the - // producer's registry — with the REAL composed factory, which + // producer's registry, with the REAL composed factory, which // closes over the opener's client + the correlation registry (both // only exist now). let factory = bindfirst_accept(topo.opener.clone(), registry, COMPOSED_RESOURCE); @@ -92,7 +92,7 @@ async fn wire_suite_with_identity(identity: alkcall::core::auth::Identity) -> Su } /// Open a BIND session through the composed accept (the SOCKS5 BIND -/// client over channels — the consumer session type). The session +/// client over channels (the consumer session type). The session /// drives the whole pipeline: open → negotiate → BIND → reply#1 (the /// factory's real `bound`) → quiet wait (the factory's accept future) /// → reply#2 (the real peer). @@ -105,7 +105,7 @@ async fn bind_session(suite: &Suite) -> BindSession { .expect("the session binds through the composed accept") } -/// THE gate: the full composed BIND story — reply#1 carries the real +/// THE gate: the full composed BIND story. Reply#1 carries the real /// `bound` (the graduation's vanilla fidelity), an app server "dials" /// (the queue accept), reply#2 carries the real peer (the forwarded /// op's `peer` param), and the pass-through data plane round-trips @@ -119,7 +119,7 @@ async fn composed_bind_full_story_bound_peer_and_round_trip() { let suite = wire_suite().await; // The BIND session rides the real composed factory (the SOCKS5 - // registration's accept seam — replaced post-hoc per-suite; the + // registration's accept seam, replaced post-hoc per-suite; the // registration reads through the shared registry per dispatch, so // the session's open dispatches with the factory in place). let mut session = bind_session(&suite).await; @@ -129,7 +129,7 @@ async fn composed_bind_full_story_bound_peer_and_round_trip() { "the SOCKS5 channel is live on the producer side" ); - // Reply#1: the REAL bound (the open-op reply's `bound` field — no + // Reply#1: the REAL bound (the open-op reply's `bound` field, no // expected-addr stand-in; the test bind's reported address). let bind_addr = session.reply1().expect("reply#1").bind_addr; assert_eq!( @@ -138,7 +138,7 @@ async fn composed_bind_full_story_bound_peer_and_round_trip() { "reply#1 advertises the real OS-chosen bound" ); - // The app server dials the far listener (the queue accept) — the + // The app server dials the far listener (the queue accept); the // producer's accept loop opens the forwarded channel toward the // opener; the correlation registry delivers it into the accept // future; reply#2 carries the real peer. @@ -183,15 +183,15 @@ async fn composed_bind_full_story_bound_peer_and_round_trip() { } // The opener side holds only channel 0 (the producer-allocated // forwarded channel's entry rides the opener's manager per its own - // adopter contract — alktunnels ADR-005). The producer side may - // retain the adopted forwarded channel (see `assert_composed_channels` - // — its adopter's session dropped with the plane; the entry reaps + // adopter contract; alktunnels ADR-005). The producer side may + // retain the adopted forwarded channel (see `assert_composed_channels`; + // its adopter's session dropped with the plane; the entry reaps // at transport EOF, not at the listener's). assert_composed_channels(&suite.topo, &[0], &[channel_id]).await; } /// The client-gone teardown (the drop-witness): dropping the BIND -/// session mid-quiet-wait releases the far listener — the accept +/// session mid-quiet-wait releases the far listener: the accept /// future owns it end-to-end (the quiet wait's `select!` drop IS the /// teardown). #[tokio::test] @@ -208,7 +208,7 @@ async fn client_gone_teardown_releases_the_listener() { drop(session); // The SOCKS5 channel reaps; the listen channel (the far listener's - // carrier) reaps too — no leaked listener, no leaked channel. + // carrier) reaps too: no leaked listener, no leaked channel. let mut socks5_gone = false; let mut listen_gone = false; for _ in 0..50 { @@ -258,7 +258,7 @@ async fn concurrent_bind_sessions_get_distinct_tokens() { let _ = s2.reply1().expect("reply#2 s2"); // Two accepts: the tokens are distinct (two listen opens minted - // distinct tokens — observably: both sessions resolve their own + // distinct tokens). Observably: both sessions resolve their own // forwarded sessions, the correlation delivered each to the right // waiter). composed_push_accept(&suite.queue, ("10.0.0.1", COMPOSED_PEER_PORT)).await; @@ -306,8 +306,8 @@ async fn concurrent_bind_sessions_get_distinct_tokens() { } /// A forwarded-open failure is per-accept isolated: an opener identity -/// without `tunnel:forwarded` makes the forwarded open fail FORBIDDEN -/// — the BIND session's accept future never resolves a forwarded +/// without `tunnel:forwarded` makes the forwarded open fail FORBIDDEN: +/// the BIND session's accept future never resolves a forwarded /// session (reply#2's mapped refusal), the listen channel stays /// usable, and the failure does not tear the listener down. #[tokio::test] @@ -316,14 +316,14 @@ async fn forwarded_open_failure_is_per_accept_isolated() { let mut session = bind_session(&suite).await; let _ = session.reply1().expect("reply#1"); - // The accept fires (the queue accept consumed — the producer's + // The accept fires (the queue accept consumed; the producer's // accept loop is alive), but the forwarded open fails FORBIDDEN. composed_push_accept(&suite.queue, ("10.0.0.1", COMPOSED_PEER_PORT)).await; // The forwarded-open failure is per-accept isolated AT THE TUNNEL // LAYER: the producer's accept loop drops that accepted connection // and keeps serving. The accept future stays parked (the SOCKS5 - // producer never learns of the tunnel-layer failure — the + // producer never learns of the tunnel-layer failure (the // quiet-wait shape is unaffected), so the queue drains both // accepts and the listener survives (the alktunnels producer // suite's `forwarded_open_failure_isolates_the_accept` pin, end to @@ -340,7 +340,7 @@ async fn forwarded_open_failure_is_per_accept_isolated() { /// The dropped listener (client gone mid-quiet-wait) leaves a /// forwarded open nowhere to go: the correlation miss drops the -/// forwarded channel (teardown-at-birth — pinned by the dead-oneshot +/// forwarded channel (teardown-at-birth, pinned by the dead-oneshot /// probe's aggregate-leak witness). #[tokio::test] async fn late_forwarded_open_after_client_gone_drops_the_channel() { @@ -356,7 +356,7 @@ async fn late_forwarded_open_after_client_gone_drops_the_channel() { composed_push_accept(&suite.queue, ("10.0.0.1", COMPOSED_PEER_PORT)).await; tokio::time::sleep(Duration::from_millis(500)).await; // Either the listener was already gone (the accept queued - // unconsumed) or the forwarded channel dropped at birth — either + // unconsumed) or the forwarded channel dropped at birth; either // way: no leaked channels on the opener side. assert_composed_channels(&suite.topo, &[0], &[0]).await; } diff --git a/tests/composed-egress.rs b/tests/composed-egress.rs index 87ef2cf..a88a220 100644 --- a/tests/composed-egress.rs +++ b/tests/composed-egress.rs @@ -1,6 +1,6 @@ //! The composed egress integration tests (ADR-008's composed variant, //! POC #2's `ComposedEgress` shape, end to end over channels): a -//! tunnel-producing peer (the alktunnels registry shape — +//! tunnel-producing peer (the alktunnels registry shape: //! `register_tunnel_direct_openable` over a UDP echo dial), the //! composed egress against it, datagram round-trips through the //! tunnel (multiple destinations → multiple tunnels), the LRU @@ -8,7 +8,7 @@ //! closed-queue → association-end shape, and framing-once. //! //! The topology: the SOCKS5-style harness (consumer calls the -//! producer's open ops) carrying alktunnels' direct op — the producer +//! producer's open ops) carrying alktunnels' direct op; the producer //! side registers `register_tunnel_direct_openable` (the //! alktunnels 0.2.0 registry shape), the consumer's //! `ChannelClient` is what the composed egress rides @@ -34,7 +34,7 @@ use alksocks::egress::{Egress, EgressError}; use alksocks::rfc1928::util::target_addr::TargetAddr; /// An echo dial closure over the alktunnels direct-op `DialFn` shape: -/// an in-process framed UDP pipe per dial (the framed-adapter shape — +/// an in-process framed UDP pipe per dial (the framed-adapter shape: /// the "target" decodes the tunnel codec and echoes each datagram). /// Counts the dialed destinations (the multiple-tunnels assertion). fn counting_udp_echo_dial(dial_count: Arc) -> TunnelDialFn { @@ -104,7 +104,7 @@ async fn egress_for( } /// A datagram round-trip through the tunnel: send to `addr`, expect -/// the echo back (the reply carries `addr + data` — inner bytes). +/// the echo back (the reply carries `addr + data`, i.e. inner bytes). async fn round_trip(egress: &mut (dyn Egress + Send), addr: &TargetAddr, payload: &[u8]) { egress .send(addr.clone(), Bytes::from(payload.to_vec())) @@ -161,8 +161,8 @@ async fn datagrams_round_trip_per_destination_multiple_tunnels() { .expect_err("next_reply stays Pending when nothing is ready"); } -/// A fresh association (no tunnel ever opened) parks in `next_reply` -/// — the stage's select is governed by the channel branch until the +/// A fresh association (no tunnel ever opened) parks in `next_reply`: +/// the stage's select is governed by the channel branch until the /// first datagram opens a tunnel (the assembly suite's drift finding: /// the ended-relay `Err` fired on the never-opened table too, killing /// every quiescent association at birth). Pinned on its own. @@ -208,7 +208,7 @@ async fn lru_eviction_reopens_evicted_destinations() { ); // The LRU (dest 53) was evicted: re-touching it re-dials (the - // cap stayed — no fourth tunnel above it). + // cap stayed; no fourth tunnel above it). round_trip(egress.as_mut(), &dest(53), b"redial").await; assert_eq!( dial_count.load(Ordering::SeqCst), @@ -234,7 +234,7 @@ async fn lru_eviction_reopens_evicted_destinations() { } /// The framing-once probe: the reply the stage would frame is exactly -/// `addr.to_be_bytes() + payload` — no double prefix. Pinned through +/// `addr.to_be_bytes() + payload`, with no double prefix. Pinned through /// the egress's own reply (the round-trip asserts equality with the /// inner form); here the composition is asserted at the wire boundary: /// the egress's reply, fed through `frame_inner` (the wire layer's @@ -281,7 +281,7 @@ async fn framing_once_replies_decode_through_the_wire_layer_exactly_once() { /// The closed-queue → association-end shape: when the tunnel's /// producer side ends (the dial's target EOFs), the relay task exits -/// (EOF on the read half) and — with every relay ended — the egress's +/// (EOF on the read half) and, with every relay ended, the egress's /// `next_reply` returns `Err` (never a hot `Ok(None)`). #[tokio::test] async fn closed_queue_yields_err_not_a_hot_none() { @@ -341,7 +341,7 @@ async fn closed_queue_yields_err_not_a_hot_none() { assert_eq!(reply, Bytes::from(expected)); // The tunnel ended → the relay exited → the queue closed. The - // next `next_reply` returns `Err` (the association-end shape) — + // next `next_reply` returns `Err` (the association-end shape); // awaited to prove it is an `Err`, not an instantly-ready // `Ok(None)` spinning the stage. let result = tokio::time::timeout(std::time::Duration::from_secs(10), egress.next_reply()) @@ -354,7 +354,7 @@ async fn closed_queue_yields_err_not_a_hot_none() { ); } -/// The domain form passes through unresolved (ADR-012 — the ssh `-D` +/// The domain form passes through unresolved (ADR-012, the ssh `-D` /// semantic): a domain destination renders its domain into the direct /// op's target verbatim (the dial closure sees the unresolved host). #[tokio::test] @@ -434,14 +434,14 @@ fn composed_shapes_compile() { /// The eviction-drives-association-end composition (review 003 D-2): /// a cap-1 table whose only entry is evicted by a send whose -/// replacement dial FAILS — the table ends with zero entries and the +/// replacement dial FAILS: the table ends with zero entries and the /// evicted tunnel's EOF drains its relay to `Ended`, so the parked /// `next_reply` hits the closed-queue condition /// (`ever_opened && live_relays == 0`) and returns the -/// association-end `Err` — the eviction path's own way to reach the +/// association-end `Err`: the eviction path's own way to reach the /// shape `closed_queue_yields_err_not_a_hot_none` pins by far-side /// dial. Also pinned: the count's `saturating_sub` discipline (the -/// stage keeps serving a *live* relay beside the ended one — the +/// stage keeps serving a *live* relay beside the ended one (the /// failing send does not poison the association). #[tokio::test] async fn eviction_to_a_failed_dial_ends_the_association_when_the_relay_ends() { @@ -474,7 +474,7 @@ async fn eviction_to_a_failed_dial_ends_the_association_when_the_relay_ends() { // The association still serves while the relay is live (the // saturating_sub discipline: a live relay keeps the stage in the - // queue branch — a second datagram round-trips on the same + // queue branch; a second datagram round-trips on the same // tunnel). round_trip(egress.as_mut(), &dest(53), b"again").await; diff --git a/tests/consumer-associate.rs b/tests/consumer-associate.rs index e7e3200..9706a32 100644 --- a/tests/consumer-associate.rs +++ b/tests/consumer-associate.rs @@ -1,4 +1,4 @@ -//! The consumer UDP ASSOCIATE integration tests — `AssociateSession` +//! The consumer UDP ASSOCIATE integration tests: `AssociateSession` //! end to end through the harness's consumer-side topology (the //! producer's real command-dispatch path over channels; the framed //! datagram stage, ADR-003, driven by the wrapper-aware client, POC @@ -7,7 +7,7 @@ //! Covered: associate → sentinel reply (received, never used for //! addressing) → send/recv round-trips (multiple in-order datagrams, //! one association); the split-datagram persistence test (reader -//! state persists across `recv_datagram` calls — the POC's +//! state persists across `recv_datagram` calls (the POC's //! 2-byte-prefix chunk finding); empty-payload datagram round-trip //! (F-2, never EOF); shutdown → the producer's stage ends and a //! fresh associate succeeds immediately; Drop teardown (drop @@ -31,7 +31,7 @@ use harness::{echo_dial, echo_egress, refusing_egress, wire_consumer_side_with}; /// The no-leaked-channels probe (the connect/bind suites' shape, /// re-pinned for the ASSOCIATE session): both managers hold only -/// channel 0 — the producer's entry reaps by its open-op wrapper; +/// channel 0 (the producer's entry reaps by its open-op wrapper); /// the probe polls past the reap race. async fn no_leaked_channels(topo: &harness::ConsumerSideTopology) { let deadline = std::time::Duration::from_secs(5); @@ -55,7 +55,7 @@ async fn no_leaked_channels(topo: &harness::ConsumerSideTopology) { /// The happy path: `associate` → the reply's sentinel `BND.ADDR` is /// received (RFC fidelity) and never used for addressing → send/recv -/// round-trips through the echo egress — multiple datagrams, in +/// round-trips through the echo egress: multiple datagrams, in /// order, on one association; the establisher witnessed the opener /// identity (CF-006). #[tokio::test] @@ -72,7 +72,7 @@ async fn associate_round_trips_multiple_datagrams_one_association() { .await .expect("the association associates"); - // The reply's BND.ADDR is the producer's sentinel (127.0.0.1:0 — + // The reply's BND.ADDR is the producer's sentinel (127.0.0.1:0, // ADR-003): received for RFC fidelity, never used for addressing. let relay = assoc .relay_addr() @@ -120,13 +120,13 @@ async fn associate_round_trips_multiple_datagrams_one_association() { no_leaked_channels(&topo).await; } -/// The split-datagram persistence test (POC #2's finding — the +/// The split-datagram persistence test (POC #2's finding: the /// reader state must persist across `recv_datagram` calls): a large /// datagram's frame (under the u16 joint bound but well over the 16 /// KB read buffer) spans multiple channel reads AND multiple -/// `recv_datagram` calls — the first call returns the earlier small +/// `recv_datagram` calls; the first call returns the earlier small /// datagram while the large one is mid-flight, the next call -/// reassembles it across its internal reads — and a trailing small +/// reassembles it across its internal reads, and a trailing small /// datagram batched in the same read batch drains through the /// pending queue. #[tokio::test] @@ -139,7 +139,7 @@ async fn recv_datagram_state_persists_across_calls() { let target = TargetAddr::Ip("10.0.0.2:53".parse().unwrap()); // The order tells the story: "first" completes early; the 60 KiB - // datagram (60007 inner bytes — legal, but over the 16 KB read + // datagram (60007 inner bytes, legal but over the 16 KB read // buffer) is mid-flight when the first call returns; "third" // batches after it. assoc @@ -166,7 +166,7 @@ async fn recv_datagram_state_persists_across_calls() { assert_eq!(&first[..], b"first", "the first datagram, in order"); // The large datagram reassembles across the call boundary (its - // mid-frame state persisted into this call) — the POC finding's + // mid-frame state persisted into this call), the POC finding's // shape. let (src, second) = tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram()) @@ -192,7 +192,7 @@ async fn recv_datagram_state_persists_across_calls() { no_leaked_channels(&topo).await; } -/// An empty-payload datagram is a legal datagram — never EOF (the +/// An empty-payload datagram is a legal datagram (never EOF; the /// F-2 invariant, end to end through the echo egress), and the /// association survives it (pre → empty → post, in order). #[tokio::test] @@ -250,7 +250,7 @@ async fn empty_payload_datagram_round_trips_and_survives() { /// Shutdown half-closes the stream: the mux's implicit-EOF sentinel /// reaches the producer's stage as EOF and the association ends -/// (association lifetime = channel lifetime) — and a fresh associate +/// (association lifetime = channel lifetime), and a fresh associate /// succeeds immediately after (the POC's test 4, from the consumer /// side; the same registration is unpoisoned). #[tokio::test] @@ -304,7 +304,7 @@ async fn shutdown_ends_the_association_and_a_fresh_associate_succeeds() { /// Drop teardown: dropping a live association mid-stream reaps the /// adopted channel entry (no leak), the producer's stage sees the /// structural EOF (the mux's implicit-EOF sentinel on the dropped -/// write half), and the connection stays healthy — a fresh associate +/// write half), and the connection stays healthy: a fresh associate /// succeeds immediately after. #[tokio::test] async fn drop_teardown_reaps_and_connection_stays_healthy() { @@ -355,7 +355,7 @@ async fn drop_teardown_reaps_and_connection_stays_healthy() { } /// A missing egress seam: the ASSOCIATE refusal surfaces the RFC -/// reply code through the session type (`CommandNotSupported` 0x07 — +/// reply code through the session type (`CommandNotSupported` 0x07, /// ADR-008's missing-callback class), and no phantom channel remains /// on either side (teardown-at-birth). #[tokio::test] @@ -364,7 +364,7 @@ async fn missing_egress_refusal_surfaces_rfc_reply_code_no_phantom_channel() { let err = AssociateSession::associate(topo.consumer.as_ref()) .await - .expect_err("no egress wired — the ASSOCIATE must refuse"); + .expect_err("no egress wired; the ASSOCIATE must refuse"); match &err { SocksSessionError::Rfc(SocksErrorKind::ReplyError(code)) => { @@ -396,7 +396,7 @@ async fn missing_egress_refusal_surfaces_rfc_reply_code_no_phantom_channel() { } /// An egress-factory failure: the mapped RFC reply code surfaces from -/// `associate` (reply#1's code — the factory refused before the +/// `associate` (reply#1's code, since the factory refused before the /// reply), and no phantom channel remains. #[tokio::test] async fn egress_factory_refusal_surfaces_the_mapped_reply_code() { @@ -423,7 +423,7 @@ async fn egress_factory_refusal_surfaces_the_mapped_reply_code() { } /// CONNECT + ASSOCIATE on the same registration (the regression -/// guard extended): one ALPN, both commands, interleaved — an +/// guard extended): one ALPN, both commands, interleaved: an /// association and a CONNECT session live concurrently on the same /// registration, both serving. #[tokio::test] diff --git a/tests/consumer-bind.rs b/tests/consumer-bind.rs index d69dc5e..007584d 100644 --- a/tests/consumer-bind.rs +++ b/tests/consumer-bind.rs @@ -1,4 +1,4 @@ -//! The consumer BIND integration tests — `BindSession` end to end +//! The consumer BIND integration tests: `BindSession` end to end //! through the harness's consumer-side topology (the producer's real //! command-dispatch path over channels; the BIND phase model, ADR-007, //! driven by the wrapper-aware client, POC #5's `read_reply2` shape). @@ -27,7 +27,7 @@ use harness::{ }; /// Both scopes: the open gate (`socks5:open`) AND the command gate -/// (`socks5:bind`) — a BIND session needs both grants explicitly +/// (`socks5:bind`); a BIND session needs both grants explicitly /// (ADR-006: never implied). fn bind_scoped() -> alkcall::core::auth::Identity { harness::both_scopes_identity() @@ -35,7 +35,7 @@ fn bind_scoped() -> alkcall::core::auth::Identity { /// The no-leaked-channels probe (the consumer-connect suite's shape, /// re-pinned for the BIND sessions): both managers hold only channel -/// 0 — the producer's entry reaps by its open-op wrapper; the probe +/// 0 (the producer's entry reaps by its open-op wrapper; the probe /// polls past the reap race. async fn no_leaked_channels(topo: &harness::ConsumerSideTopology) { let deadline = std::time::Duration::from_secs(5); @@ -67,9 +67,9 @@ fn reply_code_of(err: &SocksSessionError) -> Option { } /// The happy path: `bind` → reply#1 carries the harness accept's -/// advertised BND.ADDR (`ECHO_BIND_PORT` — the reply#1 carrier); the +/// advertised BND.ADDR (`ECHO_BIND_PORT`, the reply#1 carrier); the /// app server's out-of-band dial shape is the `BindReply1` handle; -/// `await_inbound` reads reply#2 (the peer address, 10.0.0.7:44444 — +/// `await_inbound` reads reply#2 (the peer address, 10.0.0.7:44444, /// the harness's fixed stand-in); the data plane two-pumps raw bytes /// both directions; `close` reaps the adopted channel. #[tokio::test] @@ -135,7 +135,7 @@ async fn bind_round_trip_reply1_reply2_then_pass_through() { /// The `BindDataPlane` accessors: `into_inner` yields the owned /// forked client type (read/write rides it directly); the session -/// still owns the teardown — `close` after the plane is drained reaps +/// still owns the teardown; `close` after the plane is drained reaps /// the entry. #[tokio::test] async fn bind_data_plane_accessors_into_inner_and_socket_mut() { @@ -167,7 +167,7 @@ async fn bind_data_plane_accessors_into_inner_and_socket_mut() { .expect("reply#2 read") .expect("the plane"); // `get_socket_mut` is the borrowed socket accessor (the POC - // shape) — the same stream, reached through the plane. + // shape): the same stream, reached through the plane. plane .get_socket_mut() .write_all(b"raw") @@ -190,7 +190,7 @@ async fn bind_data_plane_accessors_into_inner_and_socket_mut() { } /// The command gate (ADR-006): a `socks5:open`-only identity cannot -/// bind — the refusal surfaces as a typed session error carrying the +/// bind; the refusal surfaces as a typed session error carrying the /// RFC reply code (`ConnectionNotAllowed` 0x02), and no phantom /// channel remains on either side. #[tokio::test] @@ -222,7 +222,7 @@ async fn bind_command_gate_refusal_is_a_typed_reply_code_error() { } /// Factory failure (pre-reply#1) maps to reply#1's code, surfaced -/// through the session type — no session lives past the refusal, no +/// through the session type: no session lives past the refusal, no /// phantom listener. #[tokio::test] async fn bind_factory_failure_surfaces_reply1_code() { @@ -251,7 +251,7 @@ async fn bind_factory_failure_surfaces_reply1_code() { no_leaked_channels(&topo).await; } -/// Accept-stage failure (post-reply#1) maps to reply#2's code — +/// Accept-stage failure (post-reply#1) maps to reply#2's code, /// surfaced at `await_inbound`, no data plane; the session's channel /// is still owned (close/drop reaps it). #[tokio::test] @@ -271,7 +271,7 @@ async fn bind_accept_stage_failure_surfaces_reply2_code() { ("accept.fail", ECHO_BIND_PORT).to_target_addr().unwrap(), ) .await - .expect("reply#1 succeeded — the factory resolved"); + .expect("reply#1 succeeded; the factory resolved"); let err = match session.await_inbound().await { Err(err) => err, @@ -285,13 +285,13 @@ async fn bind_accept_stage_failure_surfaces_reply2_code() { ); // The channel entry stays adopted (reply#1 succeeded; the session - // owns it until the app closes it) — close reaps it. + // owns it until the app closes it); close reaps it. assert!(session.close().await, "the entry existed"); no_leaked_channels(&topo).await; } /// Quiet-wait garbage from the client side: a control byte after -/// reply#1 is protocol garbage — the producer ends the session with +/// reply#1 is protocol garbage: the producer ends the session with /// no data plane, so the consumer's `await_inbound` read EOFs (an I/O /// error, not a reply#2). #[tokio::test] @@ -351,7 +351,7 @@ async fn bind_quiet_wait_garbage_from_the_client_ends_the_session() { /// The three-command regression guard from the consumer side (POC #5 /// test 7's consumer-side mirror): one registration, all three -/// commands served through their consumer sessions — CONNECT +/// commands served through their consumer sessions. CONNECT /// (`Socks5ChannelSession`), BIND (`BindSession`), ASSOCIATE rides /// its task; here CONNECT + BIND coexist on one registration with the /// both-scopes identity, fresh channel per session. @@ -420,7 +420,7 @@ async fn bind_and_connect_serve_on_the_same_registration() { } /// Drop teardown: dropping a live BIND session mid-pipe reaps the -/// adopted channel entry (no leak) and the connection stays healthy — +/// adopted channel entry (no leak) and the connection stays healthy; /// a fresh bind succeeds immediately after (the CONNECT session's /// drop posture, re-pinned for BIND). #[tokio::test] @@ -445,7 +445,7 @@ async fn bind_drop_teardown_reaps_and_connection_stays_healthy() { TargetAddr::Ip(std::net::SocketAddr::from(([127, 0, 0, 1], ECHO_BIND_PORT))) ); - // Drop mid-pipe (before reply#2 — the quiet-wait phase). + // Drop mid-pipe (before reply#2, the quiet-wait phase). drop(session); no_leaked_channels(&topo).await; @@ -489,7 +489,7 @@ fn bind_session_type_is_send() { } /// The C-1 re-entry guard (review 002): a second `await_inbound` on -/// the same session cannot re-read the data plane — it returns the +/// the same session cannot re-read the data plane; it returns the /// spent shape (`Ok(None)`), and the data-plane bytes survive /// untouched (the first plane's echo round-trip is intact after the /// second call; the pinning test for the silent-corruption path). @@ -510,7 +510,7 @@ async fn double_await_inbound_returns_the_spent_shape_and_bytes_survive() { .await .expect("the session binds"); - // The first read: the plane exists (and consumes the phase — + // The first read: the plane exists (and consumes the phase: // the plane owns the stream; the session stays the teardown // owner). let mut plane = session @@ -531,7 +531,7 @@ async fn double_await_inbound_returns_the_spent_shape_and_bytes_survive() { assert_eq!(&buf, b"ping", "the first plane's round-trip"); drop(plane); - // The second call: the spent shape — `Ok(None)`, never a + // The second call: the spent shape (`Ok(None)`, never a // re-read of the live data plane. let second = session .await_inbound() @@ -540,8 +540,8 @@ async fn double_await_inbound_returns_the_spent_shape_and_bytes_survive() { assert!(second.is_none(), "the phase was consumed"); // The data-plane bytes survive: the first plane's echo - // round-trip was complete and no re-read corrupted the pipe — - // the channel is closed with the reap (the session owner's + // round-trip was complete and no re-read corrupted the pipe; the + // channel is closed with the reap (the session owner's // `close`), and a fresh bind on the same registration // round-trips with its own bytes intact. assert!(session.close().await, "the entry existed"); @@ -575,8 +575,8 @@ async fn double_await_inbound_returns_the_spent_shape_and_bytes_survive() { } /// The `reply1()`-twice re-entry path (C-1's other door, closed by -/// the D-1 one-reader decision): `reply1()` is a view — fresh handles -/// per call, no read phase — so repeated `reply1()` calls carry the +/// the D-1 one-reader decision): `reply1()` is a view, with fresh +/// handles per call and no read phase, so repeated `reply1()` calls carry the /// same advertised address without consuming anything, and the one /// normative reader (`await_inbound`) still yields exactly one plane. #[tokio::test] diff --git a/tests/consumer-connect.rs b/tests/consumer-connect.rs index 0759dcc..ea93b10 100644 --- a/tests/consumer-connect.rs +++ b/tests/consumer-connect.rs @@ -1,4 +1,4 @@ -//! The consumer-half integration tests — `Socks5ChannelSession` +//! The consumer-half integration tests: `Socks5ChannelSession` //! (CONNECT) end to end through the harness's consumer-side topology //! (the producer's real command-dispatch path over channels; the //! alkcall 0.8.0 establishment surface consumed from the consumer's @@ -6,7 +6,7 @@ //! //! Covered: connect → echo round-trip (payload bytes both //! directions); the dial-witness target forms (IP-string parses to an -//! IP target, domain passes through unresolved — ADR-012); refused +//! IP target, domain passes through unresolved (ADR-012)); refused //! dial → the RFC reply code surfaced as a typed session error; the //! scope-less FORBIDDEN open → typed open error, no session; drop //! teardown (no leaked channel entries, the connection stays @@ -45,7 +45,7 @@ fn expect_forbidden(err: &SocksSessionError) -> bool { } /// A dial that always refuses (the refusal-riding-in-stream probe's -/// dial — the RFC reply code must surface through the session type, +/// dial; the RFC reply code must surface through the session type, /// not as an open failure). fn refusing_session_dial() -> alksocks::egress::DialFn { refusing_dial(SocksDialError::Refused("target down".into())) @@ -53,7 +53,7 @@ fn refusing_session_dial() -> alksocks::egress::DialFn { /// Both managers hold only channel 0 (the phantom-channel property, /// asserted from both sides). The producer's entry is reaped by its -/// open-op wrapper when the pump handler exits — an await-able +/// open-op wrapper when the pump handler exits (an await-able /// teardown that races the consumer-side reap; this probe polls past /// the race instead of asserting instantly. async fn no_leaked_channels(topo: &ConsumerSideTopology) { @@ -160,7 +160,7 @@ async fn target_construction_ip_strings_parse_domains_pass_through() { } /// A refused dial: the RFC reply code surfaces through the session -/// type (`SocksSessionError::Rfc` — the in-stream refusal, never an +/// type (`SocksSessionError::Rfc`, the in-stream refusal, never an /// open failure), and no phantom channel remains on either side. #[tokio::test] async fn refused_dial_surfaces_rfc_reply_code_no_phantom_channel() { @@ -171,7 +171,7 @@ async fn refused_dial_surfaces_rfc_reply_code_no_phantom_channel() { ("down.test", 80u16).to_target_addr().unwrap(), ) .await - .expect_err("the dial refused — the reply code rides the session error"); + .expect_err("the dial refused; the reply code rides the session error"); match &err { SocksSessionError::Rfc(SocksErrorKind::ReplyError(code)) => { @@ -197,7 +197,7 @@ async fn refused_dial_surfaces_rfc_reply_code_no_phantom_channel() { } /// A scope-less identity: the registry's FORBIDDEN open failure is -/// the typed `SocksOpenError` through `SocksSessionError::Open` — +/// the typed `SocksOpenError` through `SocksSessionError::Open`; /// no session, no phantom channel (the open gate is the boundary). #[tokio::test] async fn forbidden_open_is_a_typed_open_error_with_no_session() { @@ -223,7 +223,7 @@ async fn forbidden_open_is_a_typed_open_error_with_no_session() { } /// Drop teardown: dropping a live session mid-pipe reaps the adopted -/// channel entry (no leak) and the connection stays healthy — a +/// channel entry (no leak) and the connection stays healthy: a /// fresh connect succeeds immediately after. #[tokio::test] async fn drop_teardown_reaps_and_connection_stays_healthy() { @@ -282,7 +282,7 @@ async fn drop_teardown_reaps_and_connection_stays_healthy() { /// Half-close semantics: shutting the write half EOFs the producer's /// pump cleanly (the EOF sentinel), while the read half stays alive /// until the target's data flows. The pump's completion is the -/// producer-side evidence (the handler tracks the data plane — R-02). +/// producer-side evidence (the handler tracks the data plane; R-02). #[tokio::test] async fn half_close_write_eof_ends_the_producer_pump() { let topo = wire_consumer_side(echo_dial()).await; diff --git a/tests/core-integration.rs b/tests/core-integration.rs index ebe5102..2b2ee11 100644 --- a/tests/core-integration.rs +++ b/tests/core-integration.rs @@ -1,4 +1,4 @@ -//! The cross-cutting integration suite — the whole protocol core's +//! The cross-cutting integration suite: the whole protocol core's //! behavior pinned in one place (the batch-1 closing task; the //! architecture-sync check's test half). //! @@ -11,7 +11,7 @@ //! served on the same `channels/socks5/sub` op, interleaved live //! sessions on one channels connection. //! - **Concurrency**: mixed concurrent sessions over one channels -//! connection — the mux/demux under load (the POCs tested +//! connection (the mux/demux under load; the POCs tested //! sequentially), pinned in both allocation directions (the raw //! topology where the producer's serving side allocates the channel //! ids, and the consumer-side topology where the consumer does). @@ -23,7 +23,7 @@ //! - **The identity/scope matrix** (ADR-006, end to end): //! `socks5:open`-only (CONNECT + ASSOCIATE serve, BIND refused 0x02 //! at the command gate), `socks5:bind`-only (fail-closed at the -//! open gate — all three commands; ADR-006's open gate requires +//! open gate, all three commands; ADR-006's open gate requires //! `socks5:open` unconditionally and the two grants are never //! implied), both scopes (everything, granted explicitly). //! - **The readiness contract** re-pinned under concurrent load (the @@ -252,13 +252,13 @@ use harness::{parked_accept_factory as parked_accept, udp_frame, udp_unframe}; // --- Seam stand-ins ---------------------------------------------------------- // The parked accept factory rides the common module (imported as -// `parked_accept` above — the deterministic quiet-wait probe; the +// `parked_accept` above, the deterministic quiet-wait probe; the // advertised bind address is the common module's 2121). /// The hot egress (the readiness contract's violator, the /// producer-associate unit test's shape): `next_reply` drains one /// queued reply if any, then returns an instantly-ready `Ok(None)` -/// instead of parking. The stage must tolerate this under load — a +/// instead of parking. The stage must tolerate this under load: a /// biased select starved into livelock would hang every session here. struct HotNoneEgress { echo: tokio::sync::mpsc::UnboundedSender, @@ -310,7 +310,7 @@ fn hot_none_egress() -> EgressFactory { /// The no-leaked-channels probe (the per-command suites' shape): both /// managers hold only channel 0; the producer's entry reaps by its -/// open-op wrapper — the probe polls past the reap race. +/// open-op wrapper; the probe polls past the reap race. async fn no_leaked_channels(topo: &ConsumerSideTopology) { let deadline = std::time::Duration::from_secs(5); let consumer_ids = topo.consumer.manager().channel_ids(); @@ -469,14 +469,14 @@ async fn consumer_bind_round_trip( /// one channels connection (the POC #2/#5 regression guard, /// complete): all three sessions are opened back to back and are live /// simultaneously; each is exercised mid-life of the others; a -/// teardown ends only its own session (the others keep serving — the +/// teardown ends only its own session (the others keep serving, the /// mux/demux isolation). #[tokio::test] async fn one_registration_all_three_commands_interleaved() { let topo = full_raw_topology().await; // Three interleaved opens on the one op; all three sessions live - // at once (CONNECT, ASSOCIATE, BIND — fresh channel per session, + // at once (CONNECT, ASSOCIATE, BIND: fresh channel per session, // convention 10). let (mut connect_raw, connect_pump) = open_adopt_pump(&topo).await; let (mut assoc_raw, assoc_pump) = raw_associate(&topo).await; @@ -551,8 +551,8 @@ async fn one_registration_all_three_commands_interleaved() { } /// Concurrency over one channels connection (the mux/demux under -/// load): nine concurrent sessions of mixed command types — three -/// CONNECTs, three ASSOCIATEs, three BINDs — interleaved by the +/// load): nine concurrent sessions of mixed command types, three +/// CONNECTs, three ASSOCIATEs, three BINDs, interleaved by the /// executor, all riding the one registration. The producer's serving /// side allocates the channel ids (the raw topology's direction). #[tokio::test] @@ -588,7 +588,7 @@ async fn concurrent_mixed_sessions_over_one_connection() { /// connection: six concurrent sessions (two of each command) driven /// by the real session types. The consumer dials here (the /// consumer-side topology), so the consumer's side allocates the -/// channel ids — the mux/demux pinned in the other allocation +/// channel ids (the mux/demux pinned in the other allocation /// direction. No channel entry leaks on either side. #[tokio::test] async fn concurrent_consumer_sessions_over_one_connection() { @@ -627,7 +627,7 @@ async fn concurrent_consumer_sessions_over_one_connection() { /// The teardown matrix, CONNECT row (wire.md §Sentinels and /// lifecycle): a client half-close ends the data plane cleanly (the /// producer's pump exits) and a fresh session of every command -/// succeeds immediately after — no connection poisoning. +/// succeeds immediately after (no connection poisoning). #[tokio::test] async fn teardown_connect_half_close_then_every_command_serves_fresh() { let topo = full_raw_topology().await; @@ -737,7 +737,7 @@ async fn teardown_associate_eof_then_every_command_serves_fresh() { /// The teardown matrix, BIND row: the client vanishing in the quiet /// wait releases the accept (the parked accept future drops with the -/// `select!` scope — the client-gone teardown, ADR-004/ADR-007) and +/// `select!` scope: the client-gone teardown, ADR-004/ADR-007) and /// fresh sessions succeed immediately after. #[tokio::test] async fn teardown_bind_client_gone_releases_the_accept_then_serves_fresh() { @@ -799,7 +799,7 @@ async fn teardown_bind_client_gone_releases_the_accept_then_serves_fresh() { /// The identity/scope matrix, `socks5:open`-only row (ADR-006): /// CONNECT and ASSOCIATE serve; BIND is refused `ConnectionNotAllowed` /// (0x02) at the command gate while CONNECT still serves the same -/// identity — ingress is never implied by egress. +/// identity; ingress is never implied by egress. #[tokio::test] async fn identity_matrix_open_only_connect_associate_serve_bind_refused_0x02() { let topo = wire_with( @@ -824,7 +824,7 @@ async fn identity_matrix_open_only_connect_associate_serve_bind_refused_0x02() { .await .expect("ASSOCIATE serves the open-only identity"); - // BIND: the command gate refuses 0x02 — RFC-faithful, no data + // BIND: the command gate refuses 0x02, RFC-faithful; no data // plane (the accept seam never ran). let (mut raw, pump) = open_adopt_pump(&topo).await; raw.greet().await.expect("greet"); @@ -858,7 +858,7 @@ async fn identity_matrix_open_only_connect_associate_serve_bind_refused_0x02() { /// The identity/scope matrix, `socks5:bind`-only row (ADR-006, as /// landed): the open op's `required_scopes` is `socks5:open` -/// unconditionally — a bind-only identity fails closed at the open +/// unconditionally, so a bind-only identity fails closed at the open /// gate for all three commands (FORBIDDEN; the establisher never /// runs; no channel anywhere). `socks5:bind` alone grants nothing /// here; the two grants are never implied, in either direction. @@ -899,7 +899,7 @@ async fn identity_matrix_bind_only_fails_closed_at_the_open_gate() { .all(|id| id == 0)); assert!( topo.identity_witness.lock().await.is_none(), - "the establisher never ran — fail-closed" + "the establisher never ran; fail-closed" ); // The same posture through the typed consumer sessions: the BIND @@ -950,8 +950,8 @@ async fn identity_matrix_both_scopes_serve_every_command() { /// The readiness contract under concurrent load (the integration /// level pin; the unit-level test is the producer-associate task's): -/// a hot `Ok(None)` egress across concurrent associations — with a -/// CONNECT interleaved — must not starve the datagram stage's channel +/// a hot `Ok(None)` egress across concurrent associations (with a +/// CONNECT interleaved) must not starve the datagram stage's channel /// branch. Datagrams still egress and echo. #[tokio::test] async fn readiness_contract_holds_under_concurrent_load() { @@ -986,7 +986,7 @@ async fn readiness_contract_holds_under_concurrent_load() { } /// The spec'd assembly shape end to end (review 002 D-2): the full -/// `Socks5Producer::builder()` chain — `dial` + `egress` + `accept` — +/// `Socks5Producer::builder()` chain (`dial` + `egress` + `accept`) /// through `.register(...)` (the builder's delegated registration, not /// the free function), serving all three commands over the harness /// topology. The one assembly path producer.md §Wiring documents is @@ -1018,7 +1018,7 @@ async fn producer_builder_registers_and_serves_all_three_commands() { .expect("ASSOCIATE serves through the builder registration"); // BIND over the builder's accept seam (reply#1 + reply#2 + - // pass-through — the full phase model). + // pass-through: the full phase model). bind_round_trip(&topo, "builder.test", b"bind") .await .expect("BIND serves through the builder registration"); diff --git a/tests/door-tcp.rs b/tests/door-tcp.rs index cfd3f2e..5739b40 100644 --- a/tests/door-tcp.rs +++ b/tests/door-tcp.rs @@ -29,7 +29,7 @@ use harness::consumer_identity; const BOUND: Duration = Duration::from_secs(10); /// The token→identity provider the cred-bearing door's producer runs -/// (CF-005 precedence (1)): the token is the mapped identity's id — +/// (CF-005 precedence (1)): the token is the mapped identity's id, /// the resolution seam's vocabulary; the password never rides it. /// Resolves the credential-table identity ids (the `door-` prefixed /// forms the tests configure) to identities carrying `socks5:open` @@ -63,7 +63,7 @@ impl IdentityProvider for DoorTokenProvider { } /// An echo TCP server on an ephemeral loopback port (the vanilla -/// client's CONNECT target — the POC #1 curl shape). +/// client's CONNECT target, the POC #1 curl shape). async fn echo_tcp_server() -> SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("local_addr"); @@ -105,14 +105,14 @@ impl VanillaClient { } /// The dial without the panic (the `serve` wrapper's bind races - /// the test's connect — the retry loop's probe). + /// the test's connect, the retry loop's probe). async fn try_dial(addr: SocketAddr) -> std::io::Result { Ok(Self { stream: TcpStream::connect(addr).await?, }) } - /// Greeting `05 02 00` (password auth offered — the cred-bearing + /// Greeting `05 02 00` (password auth offered, the cred-bearing /// door's shape) → the method-selection read. async fn greet_password(&mut self) -> u8 { self.stream @@ -217,7 +217,7 @@ impl VanillaClient { /// The NoAuth door end to end (the POC #1 curl shape): a vanilla RFC /// 1928 client through the raw pipe → CONNECT round-trip to a real /// TCP echo target. Zero protocol translation pinned: the door adds -/// nothing — the producer runs the whole RFC conversation through the +/// nothing; the producer runs the whole RFC conversation through the /// pipe (the vanilla client's greeting crosses it verbatim). #[tokio::test] async fn noauth_door_raw_pipe_vanilla_connect_round_trip() { @@ -226,7 +226,7 @@ async fn noauth_door_raw_pipe_vanilla_connect_round_trip() { let topo = harness::wire_consumer_side_with(dial, None, None, None).await; // The door takes a pre-bound listener (`serve_on`), so the test - // owns the port (the assembly seam — the same accept loop without + // owns the port (the assembly seam: the same accept loop without // the door's own bind). let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("door addr"); @@ -339,7 +339,7 @@ async fn cred_door_good_creds_connect_round_trip_with_mapped_identity() { assert_eq!(&buf, b"cred-echo"); // The establisher witness saw the mapped identity (CF-006 via the - // token resolution — CF-005 precedence (1)), never the password. + // token resolution, CF-005 precedence (1)), never the password. timeout(BOUND, async { loop { let seen = topo.identity_witness.lock().await.clone(); @@ -355,7 +355,7 @@ async fn cred_door_good_creds_connect_round_trip_with_mapped_identity() { /// The cred-bearing door: bad creds → the RFC 1929 rejection (status /// 0xff), and no channel opened (the identity witness never fires; -/// the consumer's manager holds only channel 0 — the ACL boundary +/// the consumer's manager holds only channel 0 (the ACL boundary /// held door-side). #[tokio::test] async fn cred_door_bad_creds_rejected_no_channel_opened() { @@ -429,12 +429,12 @@ async fn cred_door_bad_creds_rejected_no_channel_opened() { /// The pin: a vanilla client's command bytes must not reach the /// producer as greeting bytes (the door never pipes raw post-auth). -/// The producer's in-band conversation is re-originated — the +/// The producer's in-band conversation is re-originated: the /// producer's `accept_no_auth` reads a fresh greeting written by the /// *consumer session* (`Socks5Stream::use_stream`), not the vanilla /// client's consumed bytes. Observable as: the cred-bearing CONNECT /// round-trip works at all (raw post-auth piping would misparse the -/// command bytes as a greeting — the producer would reply 0xff-shaped +/// command bytes as a greeting (the producer would reply 0xff-shaped /// garbage), and the witness carries the mapped identity (the /// password never crossed). #[tokio::test] @@ -478,8 +478,8 @@ async fn cred_door_never_pipes_raw_post_auth() { } }); - // The vanilla client speaks greeting → password-auth → command — - // if the door piped these bytes raw post-auth, the producer's + // The vanilla client speaks greeting → password-auth → command. + // If the door piped these bytes raw post-auth, the producer's // in-band greeting read would consume `05 01 00 01 ...` (the // command request) as a method list and the conversation would // fail. The round-trip succeeding IS the pin (the re-origination @@ -507,7 +507,7 @@ async fn cred_door_never_pipes_raw_post_auth() { } /// The BIND door (cred-bearing variant): a vanilla BIND conversation -/// through the door — reply#1's advertised address passes through +/// through the door; reply#1's advertised address passes through /// unchanged (the door adds nothing), reply#2 carries the real peer, /// and the data plane round-trips (the POC #5 door shape). #[tokio::test] @@ -565,7 +565,7 @@ async fn bind_door_vanilla_conversation_reply_fidelity() { .expect("creds"); assert_eq!(status, 0x00, "accepted"); - // BIND request: 127.0.0.1:0 (ephemeral — the local accept + // BIND request: 127.0.0.1:0 (ephemeral; the local accept // provider binds the real listener). let (code1, advertised) = timeout(BOUND, vanilla.bind_v4([127, 0, 0, 1], 0)) .await @@ -617,7 +617,7 @@ async fn bind_door_vanilla_conversation_reply_fidelity() { /// The in-band refusal's reply code is re-encoded to the vanilla /// client (the RFC codec at the door boundary): an identity carrying -/// `socks5:open` only issues BIND through the cred-bearing door — +/// `socks5:open` only issues BIND through the cred-bearing door; /// the in-band command gate refuses 0x02, and the door re-encodes /// that code as reply#1 (never a bare EOF; the refused reply carries /// the placeholder zero address). @@ -672,8 +672,8 @@ async fn cred_door_in_band_refusal_surfaces_the_reply_code() { assert_eq!(status, 0x00, "accepted"); // BIND through the open-only identity: the in-band command gate's - // 0x02 rides the door's re-encoded reply (the placeholder BND.ADDR - // — a refused reply carries a zero address). + // 0x02 rides the door's re-encoded reply (the placeholder BND.ADDR: + // a refused reply carries a zero address). let (code, advertised) = timeout(BOUND, vanilla.bind_v4([127, 0, 0, 1], 0)) .await .expect("the refusal reply arrives (never a bare EOF)"); @@ -746,7 +746,7 @@ async fn idle_first_byte_timeout_closes_a_silent_connection() { } /// The session cap (ADR-014 knob 2): an accept beyond the bound is -/// refused door-side — no channel opened for the refused accept, and +/// refused door-side: no channel opened for the refused accept, and /// the door keeps serving (the loop continues). #[tokio::test] async fn session_cap_refuses_beyond_the_bound() { @@ -774,7 +774,7 @@ async fn session_cap_refuses_beyond_the_bound() { let mut holder = TcpStream::connect(addr).await.expect("first dial"); let _ = &mut holder; - // Session 2: beyond the cap — refused. The socket closes without + // Session 2: beyond the cap, so refused. The socket closes without // a reply (the door-side refusal; no RFC bytes ever flow). let refused = TcpStream::connect(addr).await.expect("second dial"); let mut probe = [0u8; 1]; @@ -789,11 +789,11 @@ async fn session_cap_refuses_beyond_the_bound() { Ok(Ok(n)) => panic!("no bytes for a beyond-cap accept, got {n}"), } - // Exactly one channel exists (the holder's — opened when it went + // Exactly one channel exists (the holder's, opened when it went // silent-past... wait, no: the holder sent no bytes either, so the // idle timeout governs it; the cap test needs the holder to stay // within the window while the second dial arrives. The holder's - // channel opens only after its first byte — silence keeps it + // channel opens only after its first byte (silence keeps it // channel-less. Assert: no channels at all for either connection // (both silent), and the door is still serving (a fresh dial // succeeds). @@ -814,7 +814,7 @@ async fn session_cap_refuses_beyond_the_bound() { } /// Per-connection failure isolation (the alktunnels per-accept -/// posture): one dead client doesn't kill the door — a client that +/// posture): one dead client doesn't kill the door. A client that /// speaks garbage (an invalid greeting) gets its connection ended, /// and the door keeps serving fresh clients end to end. #[tokio::test] @@ -839,7 +839,7 @@ async fn per_connection_failure_isolation() { }); // Client A: protocol garbage (a bad version byte through the raw - // pipe — the producer's typestate refuses in-band; the door's + // pipe; the producer's typestate refuses in-band, and the door's // pipe ends with the session). let mut garbage = VanillaClient::dial(addr).await; garbage.stream.write_all(&[0x04, 0x01, 0x00]).await.unwrap(); @@ -869,9 +869,9 @@ async fn per_connection_failure_isolation() { /// The channels-native sessions still work beside the door (the /// regression guard: the door's `TokenClient::wrap` took the client's -/// call connection — a NoAuth door never wraps, so the client's own +/// call connection: a NoAuth door never wraps, so the client's own /// sessions keep serving; a cred-bearing door's client is door-only -/// by design — the wrap's contract, asserted here at the surface +/// by design (the wrap's contract, asserted here at the surface /// level via a NoAuth topology). #[tokio::test] async fn native_sessions_work_beside_a_noauth_door() { @@ -970,7 +970,7 @@ async fn bind_session_with_token_round_trip() { /// The ASSOCIATE token twin driven directly (review 003 D-1: the /// `associate_with_token` constructor is the session family's -/// completeness twin — no door calls it, so it needs its own probe; +/// completeness twin: no door calls it, so it needs its own probe; /// the same shape as `bind_session_with_token_round_trip`): the /// open rides the payload `auth_token` (CF-005 precedence (1)), /// the ASSOCIATE's DST.ADDR is the unspecified address (the @@ -1027,8 +1027,8 @@ async fn associate_session_with_token_round_trip() { session.shutdown().await.expect("shutdown"); } -/// The `serve()` wrapper (review 003 N-8: every test drove `serve_on` -/// — the bind-then-serve wrapper round-trips too; the NoAuth raw-pipe +/// The `serve()` wrapper (review 003 N-8: the other tests all drove +/// `serve_on`; the bind-then-serve wrapper round-trips too; the NoAuth raw-pipe /// shape, the port probed up front so the wrapper's own bind lands on /// it). #[tokio::test] @@ -1038,7 +1038,7 @@ async fn front_door_serve_wrapper_round_trips() { let topo = harness::wire_consumer_side_with(dial, None, None, None).await; // A free loopback port, probed up front (the door binds exactly - // it — the wrapper's own bind decision). The connect retries: + // it, the wrapper's own bind decision). The connect retries: // the wrapper's bind races the probe's teardown (the OS may hold // the freed port briefly). let mut addr = None; @@ -1088,7 +1088,7 @@ async fn front_door_serve_wrapper_round_trips() { } /// The credential table's observability accessors (review 003 N-8: -/// `len`/`is_empty` had no caller — one probe pins both). +/// `len`/`is_empty` had no caller; one probe pins both). #[test] fn credential_table_len_and_is_empty_accessors() { let empty = alksocks::CredentialTable::new(vec![]); diff --git a/tests/door-udp.rs b/tests/door-udp.rs index 30e051d..f0a705c 100644 --- a/tests/door-udp.rs +++ b/tests/door-udp.rs @@ -1,5 +1,5 @@ //! The UDP front door's integration tests (feature `local`): the POC -//! #2 test-8 shape end to end — a real UDP echo target, the producer +//! #2 test-8 shape end to end: a real UDP echo target, the producer //! with `local::udp_egress()`, the door's relay socket, a vanilla //! RFC-header'd datagram in → the echo back RFC-header'd (the RFC //! codec boundary pinned: the in-band wire stays `{addr, data}` @@ -32,7 +32,7 @@ use harness::echo_dial; const BOUND: Duration = Duration::from_secs(10); /// A UDP echo server on a real kernel socket; returns its address -/// (the vanilla client's datagram target — the POC #2 shape). +/// (the vanilla client's datagram target, the POC #2 shape). async fn udp_echo_server() -> SocketAddr { let sock = UdpSocket::bind(("127.0.0.1", 0)) .await @@ -52,7 +52,7 @@ async fn udp_echo_server() -> SocketAddr { addr } -/// Build an RFC-header'd datagram (the vanilla client's sendto form — +/// Build an RFC-header'd datagram (the vanilla client's sendto form; /// the codec appears at the door boundary, so the test hand-rolls the /// same header: RSV(2) FRAG(1) ATYP(1) addr port). fn rfc_datagram(target: SocketAddr, payload: &[u8]) -> Vec { @@ -83,7 +83,7 @@ fn parse_rfc_datagram(wire: &[u8]) -> (SocketAddr, &[u8]) { /// the same boundary (F-2: zero-length payload round-trips, never /// EOF), and the in-band codec boundary is pinned (the door's relay /// carries only RFC-header'd datagrams; the in-band wire carries only -/// `{addr, data}` frames — the round-trip through the real echo +/// `{addr, data}` frames: the round-trip through the real echo /// target proves the two vocabularies never mixed). #[tokio::test] async fn udp_door_vanilla_client_round_trip_and_empty_datagram() { @@ -125,7 +125,7 @@ async fn udp_door_vanilla_client_round_trip_and_empty_datagram() { assert_eq!(payload, b"pre"); // The empty datagram (F-2): a zero-length payload round-trips, - // never EOF — the association stays healthy after it. + // never EOF: the association stays healthy after it. client .send_to(&rfc_datagram(echo_addr, b""), relay_addr) .await @@ -141,7 +141,7 @@ async fn udp_door_vanilla_client_round_trip_and_empty_datagram() { "the empty datagram round-trips (never EOF)" ); - // Post: the association survived — another datagram round-trips. + // Post: the association survived; another datagram round-trips. client .send_to(&rfc_datagram(echo_addr, b"post"), relay_addr) .await @@ -155,7 +155,7 @@ async fn udp_door_vanilla_client_round_trip_and_empty_datagram() { // The in-band vocabulary pin: the association's adopted channel // exists (the door opened it in-band via AssociateSession) and the - // producer's stage saw the real echo target — the datagrams were + // producer's stage saw the real echo target (the datagrams were // carried as `{addr, data}` frames (the echo server, which speaks // only raw kernel datagrams, answered), never RFC-header'd bytes. assert!( @@ -170,7 +170,7 @@ async fn udp_door_vanilla_client_round_trip_and_empty_datagram() { /// The reader-state pin through the door's bridge (review 003 R-1, /// the POC #2 finding carried to the doors): two replies arriving -/// back-to-back (the doubled echo — both frames ride the producer's +/// back-to-back (the doubled echo: both frames ride the producer's /// frame writer; the door's channel read may batch them into one /// chunk) both reach the vanilla client. A bridge that re-created /// its decoder per read lost the second datagram. @@ -198,7 +198,7 @@ async fn batched_in_band_replies_both_reach_the_vanilla_client() { }); // One datagram in → two echo datagrams out (both from the relay - // socket, both carrying the target as their source address — the + // socket, both carrying the target as their source address; the // bridge's pending queue drained in order, none lost). client .send_to(&rfc_datagram(echo_addr, b"once"), relay_addr) @@ -228,7 +228,7 @@ async fn batched_in_band_replies_both_reach_the_vanilla_client() { } /// FRAG ≠ 0 is refused at the door (the v1 posture): the fragment is -/// dropped — nothing is egressed — and the association stays healthy +/// dropped (nothing is egressed), and the association stays healthy /// (a following normal datagram round-trips). #[tokio::test] async fn frag_nonzero_refused_and_association_stays_healthy() { @@ -254,7 +254,7 @@ async fn frag_nonzero_refused_and_association_stays_healthy() { }); // A FRAG=1 datagram: dropped at the door's codec boundary (no - // echo comes back — the datagram never went in-band). + // echo comes back, because the datagram never went in-band). let SocketAddr::V4(v4) = echo_addr else { panic!("v4 target") }; @@ -268,7 +268,7 @@ async fn frag_nonzero_refused_and_association_stays_healthy() { .expect("send frag"); // A normal datagram right after: the FRAG drop left the door - // serving — the association is healthy. + // serving: the association is healthy. client .send_to(&rfc_datagram(echo_addr, b"healthy"), relay_addr) .await @@ -297,7 +297,7 @@ async fn frag_nonzero_refused_and_association_stays_healthy() { /// The client idle bound (ADR-014's door-side defense, constructor /// config): a client that sends no datagrams within the window gets -/// its association closed door-side — the door's task ends, the +/// its association closed door-side: the door's task ends, the /// association's channel is reaped (association lifetime = channel /// lifetime, ADR-003), and a fresh association succeeds after the /// teardown (the connection is healthy). @@ -312,7 +312,7 @@ async fn idle_bound_closes_an_idle_association_and_a_fresh_one_succeeds() { ) .await; - // First association: never sends — the idle bound fires within + // First association: never sends, so the idle bound fires within // (window + slack) and the door's task ends. let relay = UdpSocket::bind("127.0.0.1:0").await.expect("bind relay"); let door = UdpFrontDoor::builder() @@ -348,7 +348,7 @@ async fn idle_bound_closes_an_idle_association_and_a_fresh_one_succeeds() { ); // A fresh association succeeds after the teardown (the connection - // is healthy — the POC's strongest observable). + // is healthy, the POC's strongest observable). let relay2 = UdpSocket::bind("127.0.0.1:0").await.expect("bind relay 2"); let relay2_addr = relay2.local_addr().unwrap(); let door2 = UdpFrontDoor::builder() @@ -379,7 +379,7 @@ async fn idle_bound_closes_an_idle_association_and_a_fresh_one_succeeds() { assert_eq!(payload, b"fresh"); } -/// A fresh association succeeds after teardown — driven through the +/// A fresh association succeeds after teardown, driven through the /// same relay-port shape as the happy path (the idle test's follow-up, /// pinned on its own so the "connection healthy" observable stands /// alone). @@ -456,8 +456,8 @@ async fn fresh_association_succeeds_after_teardown() { serve2.abort(); } -/// The `serve()` wrapper (review 003 N-8: every test drove `serve_on` -/// — the bind-then-serve wrapper round-trips too; the port is +/// The `serve()` wrapper (review 003 N-8: every other test drove +/// `serve_on`; the bind-then-serve wrapper round-trips too; the port is /// configured explicitly so the vanilla client can address the /// relay). #[tokio::test] @@ -473,7 +473,7 @@ async fn udp_door_serve_wrapper_round_trips() { .await; // A free loopback port, probed up front (the door binds exactly - // it — the wrapper's own bind decision). + // it, the wrapper's own bind decision). let probe = UdpSocket::bind("127.0.0.1:0").await.expect("probe bind"); let relay_addr = probe.local_addr().expect("probe addr"); drop(probe); @@ -504,7 +504,7 @@ async fn udp_door_serve_wrapper_round_trips() { /// The NAT port-rebind shape (OQ-SK2-09's option-2 fix): the client's /// source IP pins on the first datagram, and a later datagram from the -/// same IP on a *new source port* moves the reply path — the rebind +/// same IP on a *new source port* moves the reply path (the rebind /// keeps receiving replies. The RFC drop rule's complement: replies go /// to the latest-seen port from the pinned IP. #[tokio::test] @@ -545,7 +545,7 @@ async fn nat_port_rebind_keeps_receiving_replies() { // The "NAT rebind": a fresh socket, same IP, new source port. The // datagram is accepted (same IP) and the reply goes to the *new* - // port — the rebind keeps receiving. + // port; the rebind keeps receiving. let client_b = UdpSocket::bind("127.0.0.1:0").await.expect("client B"); client_b .send_to(&rfc_datagram(echo_addr, b"after"), relay_addr) @@ -569,8 +569,8 @@ async fn nat_port_rebind_keeps_receiving_replies() { } /// The RFC 1928 peer posture's drop rule (OQ-SK2-09's option-2 fix): -/// a datagram from a *different source IP* is dropped — never -/// egressed — and the association stays healthy for the pinned peer. +/// a datagram from a *different source IP* is dropped (never +/// egressed), and the association stays healthy for the pinned peer. /// (Loopback-only IPs are used: the pinned peer keeps receiving, the /// foreign-IP sender's datagram disappears.) #[tokio::test] @@ -609,9 +609,9 @@ async fn foreign_source_ip_datagrams_dropped_and_association_stays_healthy() { .expect("recv"); assert_eq!(parse_rfc_datagram(&rx[..n]).1, b"pinned"); - // A "foreign-IP" sender: 127.0.0.2 (loopback, a different IP — + // A "foreign-IP" sender: 127.0.0.2 (loopback, a different IP, // bindable without privileges). Its datagram is dropped at the - // door's peer gate — no echo comes back to it, and nothing is + // door's peer gate: no echo comes back to it, and nothing is // egressed to the echo target on its behalf. let foreign = UdpSocket::bind("127.0.0.2:0").await.expect("foreign bind"); foreign @@ -627,7 +627,7 @@ async fn foreign_source_ip_datagrams_dropped_and_association_stays_healthy() { ); // The pinned peer is unaffected: its next datagram round-trips - // (the drop left the association healthy — the gate never evicts). + // (the drop left the association healthy; the gate never evicts). client .send_to(&rfc_datagram(echo_addr, b"still"), relay_addr) .await diff --git a/tests/harness.rs b/tests/harness.rs index 760d328..d50d6c3 100644 --- a/tests/harness.rs +++ b/tests/harness.rs @@ -1,15 +1,15 @@ -//! Test harness — the producer↔consumer wiring over a +//! Test harness: the producer↔consumer wiring over a //! `tokio::io::duplex` carrying the channels 8-byte chunk format. #![allow(dead_code)] //! //! Topology (the alktunnels harness shape, adapted to this crate's -//! seams; the alkcall 0.8.0 establishment surface under test — this +//! seams; the alkcall 0.8.0 establishment surface under test; this //! is the crate's first consumption of it): //! -//! - **producer** (connect side of the channels connection — it runs +//! - **producer** (connect side of the channels connection; it runs //! `ChannelClient::from_connection_with_serving`): serves the socks5 //! open op + the generic channel ops on its serving registry. Its -//! manager allocates the channel IDs (`ChannelSide::Connect` — the +//! manager allocates the channel IDs (`ChannelSide::Connect`, whose //! serving side allocates; the initiator adopts). //! - **consumer** (accept side): the `ChannelsAdapter` + capturing //! install hook. The hook captures the consumer's channel-0 @@ -20,13 +20,13 @@ //! The POC #1 wiring requirement is load-bearing here //! (producer.md §Harness wiring requirement): the accept-side //! install hook must call `channel0_conn.set_identity(resolved)` -//! before building the `CallConnection` — without it, scope-gated +//! before building the `CallConnection`; without it, scope-gated //! opens fail closed with `FORBIDDEN "authentication required"` //! (correct behavior, implicit wiring). The harness makes the //! requirement explicit and tested. //! //! Everything is in-process: no sockets, no `local` feature. The -//! harness is dev-only (`tests/`) — it stays out of the wasm build. +//! harness is dev-only (`tests/`), so it stays out of the wasm build. //! The `allow(dead_code)` rides the alktunnels posture: each test //! binary compiles the harness and uses a different subset. @@ -64,7 +64,7 @@ use alksocks::Socks5Params; // --- Shared helpers (the single-sourced shapes; review 002 N-5) ----------- // // The dial/egress/accept stand-ins and the raw RFC 1928 client trio -// live in `tests/common/mod.rs` — the same file the lib test crate +// live in `tests/common/mod.rs`, the same file the lib test crate // includes via `#[path]` (unit tests in `src/` cannot import // `tests/harness.rs`, but they can share this module). The harness // declares the module (path-included from `tests/`) and re-exports @@ -95,7 +95,7 @@ pub fn consumer_identity() -> Identity { } } -/// The BIND-scope identity: `socks5:bind` ONLY — no `socks5:open`. +/// The BIND-scope identity: `socks5:bind` ONLY; no `socks5:open`. /// The command-gate probe's identity. pub fn bind_identity() -> Identity { Identity { @@ -106,7 +106,7 @@ pub fn bind_identity() -> Identity { } /// Both scopes: `socks5:open` AND `socks5:bind` (a deployment -/// granting both grants both explicitly — never implied, ADR-006). +/// granting both grants both explicitly; never implied, ADR-006). pub fn both_scopes_identity() -> Identity { Identity { id: "consumer-both".to_string(), @@ -117,12 +117,12 @@ pub fn both_scopes_identity() -> Identity { /// The BIND-capable identity the BIND integration tests open with: /// both scopes (the open gate needs `socks5:open`, the command gate -/// needs `socks5:bind` — a session needs both grants explicitly). +/// needs `socks5:bind`; a session needs both grants explicitly). pub fn bind_scoped_identity() -> Identity { both_scopes_identity() } -/// The scoped token — resolves to [`consumer_identity`] via +/// The scoped token; it resolves to [`consumer_identity`] via /// [`TestIdProvider`] (the hub-forwarding path's shape). pub const TEST_AUTH_TOKEN: &str = "test-token-with-socks5-open-scope"; @@ -154,7 +154,7 @@ impl IdentityProvider for TestIdProvider { // producer-side registrations. /// The advertised BND.ADDR of [`echo_accept`] (in-process, no real -/// listener — the address is the reply#1 carrier only). +/// listener; the address is the reply#1 carrier only). pub const ECHO_BIND_PORT: u16 = 2121; /// An echo accept factory: reply#1 advertises a static in-process @@ -236,14 +236,14 @@ pub fn refusing_accept(err: BindAcceptError) -> BindAcceptFactory { // --- The producer registration helper -------------------------------------- /// What the establisher's per-call auth carried as the opener -/// identity id (the CF-006 probe — `None` until an open runs). +/// identity id (the CF-006 probe; `None` until an open runs). pub type IdentityWitness = Arc>>; /// The session-gate establisher: records the opener identity (the /// CF-006 seam the harness witnesses); returns -/// `Establishment::default()` — no plan needed (the dial is +/// `Establishment::default()`, since no plan is needed (the dial is /// in-stream, in the pump handler). A scope refusal is the registry -/// ACL gate's job (it runs before this ever runs — ADR-006). +/// ACL gate's job (it runs before this ever runs; ADR-006). pub fn socks5_session_establisher( witness: IdentityWitness, ) -> alkcall::channels::operations::OpenEstablisher { @@ -259,7 +259,7 @@ pub fn socks5_session_establisher( /// The smoke-test's minimal CONNECT producer path (the POC #1 /// `serve_socks5_over` shape, inline): negotiate no-auth → read the /// command → dial via the injected closure → reply → `pump_bidi` -/// inline (R-02 — the handler's `JoinHandle` tracks the data-plane +/// inline (R-02: the handler's `JoinHandle` tracks the data-plane /// lifetime; early return = teardown-at-birth). The producer task /// replaces this with the real command-dispatch pump; the harness /// keeps the smoke test producer-independent. @@ -307,7 +307,7 @@ where } /// The pump handler over the minimal CONNECT path (the smoke test's -/// producer stand-in — the POC #1 shape). Awaited inline (R-02). +/// producer stand-in, the POC #1 shape). Awaited inline (R-02). fn make_minimal_connect_handler(dial: DialFn) -> alkcall::channels::operations::OpenHandler { Arc::new( move |_input: serde_json::Value, @@ -329,7 +329,7 @@ fn make_minimal_connect_handler(dial: DialFn) -> alkcall::channels::operations:: } /// The producer-side registration mode (what [`wire_with`] registers -/// on the producer's serving registry post-hoc — W2's shape; the +/// on the producer's serving registry post-hoc, W2's shape; the /// single place the producer-side wiring lives, so the per-command /// tasks only write tests). #[derive(Clone)] @@ -353,25 +353,25 @@ pub enum RegisterMode { /// registration rides [`alksocks::producer::Socks5Producer:: /// builder`] + [`alksocks::producer::Socks5Producer::register`] /// (the builder's `dial`/`egress`/`accept` setters and the - /// delegated registration — the surface D-2 guards). + /// delegated registration: the surface D-2 guards). ProducerBuilder { dial: DialFn, egress: Option, accept: Option, bind_policy: Option, }, - /// No socks5 openable — only the generic channel ops (the + /// No socks5 openable; only the generic channel ops (the /// unknown-op / registry-gate probe's shape). NoSocks5, } /// Register the SOCKS5 open op on a `ChannelCore` + the session's -/// dispatch registry — the alkcall 0.8.0 establishment surface +/// dispatch registry: the alkcall 0.8.0 establishment surface /// (`register_openable_with_establisher`) consumed for this crate's /// shape. The producer task's `register_socks5_openable` succeeds /// this helper (the smoke test's stand-in handler is replaced then; -/// the registration shape — spec, establisher, handler, auth, -/// timeout — stays). +/// the registration shape (spec, establisher, handler, auth, +/// timeout) stays). pub fn register_socks5_openable_minimal( core: &ChannelCore, on_registry: &Arc, @@ -395,20 +395,20 @@ pub struct Topology { /// The producer's client (connect side, serving). Its manager /// allocated the socks5 channels (`ChannelSide::Connect`). pub producer: Arc, - /// The consumer's manager (accept side) — the initiator adopts + /// The consumer's manager (accept side): the initiator adopts /// the producer-allocated IDs here; probes reap via it. pub consumer_manager: ChannelManager, - /// The consumer's channel-0 call connection — its surface for + /// The consumer's channel-0 call connection: its surface for /// calling the producer's open op (and channel/close). pub consumer_call: Arc, /// The producer's serving registry (the socks5 openable + - /// generic channel ops live here — probes). + /// generic channel ops live here; probes). pub producer_registry: Arc, - /// The consumer's serving registry (empty by default — the + /// The consumer's serving registry (empty by default; the /// consumer serves no ops in the default topology). pub consumer_registry: Arc, /// What the establisher's per-call auth carried (the CF-006 - /// probe — `None` until an open runs). + /// probe; `None` until an open runs). pub identity_witness: IdentityWitness, } @@ -416,7 +416,7 @@ pub struct Topology { /// /// Identity wiring (the load-bearing POC #1 requirement): the /// accept-side install hook calls `channel0_conn.set_identity` -/// before building the `CallConnection` — the serving dispatch reads +/// before building the `CallConnection`; the serving dispatch reads /// it as the caller identity (CF-005 (b)'s accept-side analogue; /// without it, scope-gated opens fail closed with `FORBIDDEN`). The /// transport identity attaches to the dialing connection before @@ -424,7 +424,7 @@ pub struct Topology { /// propagates automatically on the connect side. /// /// `serving_identity` overrides the dispatch-resolved identity per -/// `ServingConfig.identity` (CF-005 (a) probe shape — `None` in the +/// `ServingConfig.identity` (CF-005 (a) probe shape; `None` in the /// primary path). `provider` resolves payload tokens (the /// hub-forwarding path). pub async fn wire_with( @@ -464,7 +464,7 @@ pub async fn wire_with( let (writer, reader) = split_single_stream(channel0_bidi); // The load-bearing POC #1 wiring requirement: attach // the resolved identity to channel 0 BEFORE building - // the `CallConnection` — the serving dispatch reads + // the `CallConnection`; the serving dispatch reads // it as the caller identity; without it, scope-gated // opens fail closed (`FORBIDDEN "authentication // required"`). Explicit here; tested below. @@ -475,7 +475,7 @@ pub async fn wire_with( )); // The consumer serves the ops pre-registered on its - // serving registry (empty in the default topology — + // serving registry (empty in the default topology; // the dispatcher keeps the call loop alive either // way; its read loop resolves the producer's // responses to the consumer's pending entries). @@ -494,7 +494,7 @@ pub async fn wire_with( // --- transport ------------------------------------------------------ // The producer dials the transport (connect side): its connection // gets the transport identity BEFORE `from_connection_with_serving` - // (the mTLS/QUIC posture; CF-005 (b) — the primary caller-identity + // (the mTLS/QUIC posture; CF-005 (b), the primary caller-identity // path). The accept side resolves it out-of-band (modeled in the // hook above). let (producer_end, consumer_end) = tokio::io::duplex(64 * 1024); @@ -534,7 +534,7 @@ pub async fn wire_with( let producer_client = Arc::new(producer_client); // The generic channel ops ride the producer's serving registry - // (channel/close tears a channel down out-of-band — review 007's + // (channel/close tears a channel down out-of-band per review 007's // recipe). let ops = ChannelOperations::with_default_policy(producer_client.manager().clone()); ops.register_on(&producer_op_registry) @@ -645,7 +645,7 @@ pub async fn wire(dial: DialFn) -> Topology { /// The consumer-session topology (`Socks5ChannelSession::connect` is /// normative for this shape): the consumer dials the transport -/// (connect side — `ChannelClient::from_connection`, a pure consumer +/// (connect side; `ChannelClient::from_connection`, a pure consumer /// serving nothing) and the producer accepts (the adapter + an /// install hook registering the SOCKS5 openable over the injected /// seams on the producer's serving registry). @@ -654,19 +654,19 @@ pub struct ConsumerSideTopology { /// manager adopted the producer-allocated channel IDs; the /// session reaps via it. pub consumer: Arc, - /// The producer's manager (accept side, serving) — for asserting + /// The producer's manager (accept side, serving): for asserting /// the handler-exit reaping (no leaked channel entries). pub producer_manager: ChannelManager, /// What the establisher's per-call auth carried (the CF-006 - /// probe — `None` until an open runs). + /// probe; `None` until an open runs). pub identity_witness: IdentityWitness, } /// Wire the consumer-side topology over one duplex transport: -/// consumer dials (identity attached before `from_connection` — the +/// consumer dials (identity attached before `from_connection`: the /// CF-005 (b) transport-identity path), producer accepts (the hook /// attaches the resolved identity to channel 0 before the -/// `CallConnection` — the load-bearing POC #1 wiring requirement). +/// `CallConnection`: the load-bearing POC #1 wiring requirement). pub async fn wire_consumer_side_with( dial: DialFn, egress: Option, @@ -684,7 +684,7 @@ pub async fn wire_consumer_side_with( } /// The consumer-side topology with the producer-side dispatch's -/// identity provider configured (the CF-005 precedence (1) seam — the +/// identity provider configured (the CF-005 precedence (1) seam: the /// payload `auth_token` → `identity_provider` resolution path the /// cred-bearing door's token sessions ride). Everything else matches /// [`wire_consumer_side_with`]. @@ -791,16 +791,16 @@ pub async fn wire_consumer_side(dial: DialFn) -> ConsumerSideTopology { // --- The composed suite's topology (the alktunnels direct op) ------- /// The tunnel-direct topology (the composed suite's shape): the -/// consumer dials the transport (a full `ChannelClient` — the +/// consumer dials the transport (a full `ChannelClient`, the /// composed egress's direct-op caller) and the producer accepts (the -/// adapter + an install hook registering the alktunnels direct op — -/// `register_tunnel_direct_openable` over the given dial — on the +/// adapter + an install hook registering the alktunnels direct op, +/// `register_tunnel_direct_openable` over the given dial, on the /// producer's serving registry). The resolved identity carries /// `tunnel:direct` (the direct op's scope gate). pub struct TunnelDirectTopology { - /// The consumer's client — `TunnelSession::open_direct` rides it. + /// The consumer's client: `TunnelSession::open_direct` rides it. pub consumer: Arc, - /// The producer's manager (accept side, serving) — probes. + /// The producer's manager (accept side, serving): probes. pub producer_manager: ChannelManager, } @@ -904,13 +904,13 @@ impl RawRfcClient where S: AsyncRead + AsyncWrite + Unpin, { - /// Wrap a stream (a channel's `BiStream`, a duplex half — + /// Wrap a stream (a channel's `BiStream`, a duplex half, anything /// substrate-agnostic). pub fn new(stream: S) -> Self { Self { stream } } - /// Greeting (`05 01 00` — version 5, one method, no-auth) → the + /// Greeting (`05 01 00`: version 5, one method, no-auth) → the /// method-selection read (`05 00`). Returns the selected method /// byte. pub async fn greet(&mut self) -> Result { @@ -928,7 +928,7 @@ where } /// Read one reply (the 4-byte head + the BND.ADDR per its ATYP). - /// Returns `(reply_code, bnd_addr_bytes)` — the address bytes as + /// Returns `(reply_code, bnd_addr_bytes)`: the address bytes as /// the RFC form carries them (ATYP + addr + port). pub async fn read_reply(&mut self) -> Result<(u8, Vec), std::io::Error> { let (code, _atyp, addr) = raw_read_reply(&mut self.stream).await?; @@ -953,15 +953,15 @@ where // --- The composed accept's topology (the bind-first tunnel producer) --- -/// The bind-first topology (the composed accept suite's shape — +/// The bind-first topology (the composed accept suite's shape, /// alktunnels' `wire_listener_bindfirst` adapted): the opener dials -/// the transport (the SOCKS5 consumer side — its `ChannelClient` is +/// the transport (the SOCKS5 consumer side; its `ChannelClient` is /// what `TunnelListener::open` and the SOCKS5 BIND client ride) and /// the tunnel producer accepts (the adapter + an install hook /// registering the alktunnels bind-first openable over the given /// bind/accept-loop injections AND the SOCKS5 producer openable with /// the composed accept factory). One connection carries both -/// protocols' ops — the producer serves `alk/tunnel`'s open op and the +/// protocols' ops; the producer serves `alk/tunnel`'s open op and the /// forwarded op, plus the SOCKS5 open op, on one registry. /// /// The opener identity carries `tunnel:open` + `tunnel:forwarded` + @@ -969,13 +969,13 @@ where /// story crosses: the listen open, the forwarded ingress, the SOCKS5 /// session open, the BIND command gate). pub struct BindFirstTopology { - /// The opener's client — `TunnelListener::open` + the SOCKS5 BIND + /// The opener's client: `TunnelListener::open` + the SOCKS5 BIND /// session + the composed accept factory all ride it. pub opener: Arc, - /// The opener's serving registry — the forwarded op lands here + /// The opener's serving registry: the forwarded op lands here /// (via `register_forwarded_serving`). pub opener_registry: Arc, - /// The producer's manager (accept side, serving) — reaping probes. + /// The producer's manager (accept side, serving): reaping probes. pub producer_manager: ChannelManager, /// The producer's serving registry (probes). pub producer_registry: Arc, @@ -985,7 +985,7 @@ pub struct BindFirstTopology { } /// Wire the bind-first topology over one duplex transport. The -/// producer's install hook registers — on its serving registry — the +/// producer's install hook registers, on its serving registry, the /// alktunnels bind-first openable (over the given `bind` + the given /// accept loop) and the SOCKS5 openable over a composed accept factory /// built from `register_forwarded_serving` on the OPENER side... but @@ -993,7 +993,7 @@ pub struct BindFirstTopology { /// exists after the opener's client is built. The hook therefore /// defers the forwarded registration: the test wires it post-hoc via /// [`alksocks::composed::register_forwarded_serving`] on -/// `opener_registry` (W2's post-hoc posture — the dispatcher reads +/// `opener_registry` (W2's post-hoc posture: the dispatcher reads /// through the shared `Arc` per dispatch). pub async fn wire_bindfirst_composed( registry: alktunnels::producer::ResourceRegistry, @@ -1044,7 +1044,7 @@ pub async fn wire_bindfirst_composed( ops.register_on(®istry_arc) .expect("register channel ops on producer"); - // The forwarded-open caller (the call plane — alktunnels + // The forwarded-open caller (the call plane, alktunnels // harness's shape): call + adopt on the serving session's // `CallConnection`. let call_for_opener = Arc::clone(&call_connection); @@ -1106,7 +1106,7 @@ pub async fn wire_bindfirst_composed( // topology pieces that only exist after the opener's // client is built (the composed accept's factory needs the // opener's `ChannelClient`), the registration defers to - // the post-hoc W2 shape — the caller registers it on the + // the post-hoc W2 shape; the caller registers it on the // returned `producer_registry` (the dispatcher reads // through the shared `Arc` per // dispatch). @@ -1196,7 +1196,7 @@ pub fn composed_opener_identity() -> Identity { } } -/// [`composed_opener_identity`] minus the forwarded grant — the +/// [`composed_opener_identity`] minus the forwarded grant: the /// per-accept isolation probe's identity (the forwarded open fails /// FORBIDDEN; the accept is consumed, the listener lives). pub fn composed_scopeless_opener_identity() -> Identity { @@ -1213,7 +1213,7 @@ pub fn composed_scopeless_opener_identity() -> Identity { /// The composed suite's full identity: the five gates the composed /// three-command story crosses (`tunnel:open` + `tunnel:direct` + -/// `tunnel:forwarded` + `socks5:open` + `socks5:bind`) — the assembly +/// `tunnel:forwarded` + `socks5:open` + `socks5:bind`); the assembly /// suite's producer registration serves the direct op (CONNECT + /// ASSOCIATE's tunnels) beside the bind-first op (BIND's listener). pub fn composed_full_opener_identity() -> Identity { @@ -1233,18 +1233,18 @@ pub fn composed_full_opener_identity() -> Identity { // --- The composed accept's shared machinery (review 002 N-5) ---------------- // // The bind/accept-queue stand-ins and the channel-set probe the -// composed suites consume — single-sourced here now that two suites +// composed suites consume, single-sourced here now that two suites // (composed-accept + assembly-integration) drive the same shapes. /// The resource the bind-first producer serves (the composed suites' -/// only subscription — the registry namespace gate). +/// only subscription, the registry namespace gate). pub const COMPOSED_RESOURCE: &str = "bind"; -/// The bound address the test bind reports (the "OS-chosen" stand-in — +/// The bound address the test bind reports (the "OS-chosen" stand-in; /// the test's queue listener owns it; fidelity is the assert's job). pub const COMPOSED_BOUND_PORT: u16 = 42113; -/// The peer address the accepted pairs report (the "app server" — the +/// The peer address the accepted pairs report (the "app server"; the /// forwarded op's `peer` param carries it to reply#2). pub const COMPOSED_PEER_PORT: u16 = 55231; @@ -1265,7 +1265,7 @@ pub fn composed_test_bind() -> alktunnels::producer::BindFn { } /// The accept-queue pair the test accept loop pops from (the alktunnels -/// listener test's shape: a VecDeque + Notify — one accepted pair per +/// listener test's shape: a VecDeque + Notify, one accepted pair per /// dispatcher call). pub type ComposedAcceptQueue = ( Arc< @@ -1287,8 +1287,8 @@ pub fn composed_fresh_queue() -> ComposedAcceptQueue { } /// An accepted "connection" for the test accept loop: a duplex pair -/// with an echo task on the far end (the app server's socket stand-in -/// — the data plane's echo confirms the full round trip). +/// with an echo task on the far end (the app server's socket stand-in; +/// the data plane's echo confirms the full round trip). fn composed_accepted_halves( peer_addr: (&str, u16), ) -> ( @@ -1362,10 +1362,10 @@ pub async fn composed_push_accept(queue: &ComposedAcceptQueue, peer: (&str, u16) /// The composed suites' no-leak probe: the opener side ends with /// channel 0 (+ the live channels the test names); the producer side /// ends with channel 0, the SOCKS5 channels the test names, AND any -/// odd id — the producer-side ADOPTED forwarded channels +/// odd id: the producer-side ADOPTED forwarded channels /// (opener-allocated ids, adopted inside the producer's /// `open_forwarded` caller; per-session ownership per alktunnels -/// ADR-005 — their entries reap at transport EOF, not at the +/// ADR-005; their entries reap at transport EOF, not at the /// listener's teardown; upstream's own bind-first suites make the /// same concession in `no_stray_channels`). pub async fn assert_composed_channels( @@ -1448,7 +1448,7 @@ mod tests { .await .expect("dial resolves"); let (mut probe_far, probe_near) = tokio::io::duplex(64 * 1024); - // The pump feeds the handle against the probe's near end — + // The pump feeds the handle against the probe's near end, // exactly the data-plane shape the producer handler runs. let pump = tokio::spawn(pump_bidi(probe_near, handle.read, handle.write)); probe_far.write_all(b"ping").await.expect("write"); @@ -1577,7 +1577,7 @@ mod tests { /// A hand-rolled minimal RFC server over a duplex: greeting → /// method selection (`05 00`) → reply read (the differential - /// suite's mock-server pattern) — the raw client's counterparty. + /// suite's mock-server pattern); it is the raw client's counterparty. async fn hand_rolled_server(mut stream: tokio::io::DuplexStream, reply_code: u8) { let mut greeting = [0u8; 3]; stream.read_exact(&mut greeting).await.expect("greeting"); @@ -1587,7 +1587,7 @@ mod tests { .await .expect("method selection"); // Read the command request head (VER CMD RSV ATYP) + the rest - // per ATYP (a domain here — the helper's test target form). + // per ATYP (a domain here, the helper's test target form). let mut head = [0u8; 4]; stream.read_exact(&mut head).await.expect("command head"); assert_eq!(head[1], consts::SOCKS5_CMD_TCP_CONNECT); @@ -1614,7 +1614,7 @@ mod tests { /// The raw client round-trips greeting + command + reply against /// a hand-rolled server over a duplex (the task's acceptance - /// criterion — the raw helper is producer-independent). + /// criterion: the raw helper is producer-independent). #[tokio::test] async fn raw_rfc_client_round_trips_against_a_hand_rolled_server() { let (client_end, server_end) = tokio::io::duplex(1024); @@ -1653,7 +1653,7 @@ mod tests { /// The full loop: harness up → producer registered → consumer /// opens a channel → raw RFC CONNECT conversation → echo /// round-trip. The producer path is the minimal CONNECT stand-in - /// (the POC #1 inline shape) — the producer task replaces it. + /// (the POC #1 inline shape); the producer task replaces it. #[tokio::test] async fn smoke_connect_echo_round_trip_over_the_harness() { let topo = wire(echo_dial()).await; @@ -1723,13 +1723,13 @@ mod tests { /// The scope-less identity fails closed: an identity with no /// `socks5:open` gets the registry's `FORBIDDEN` (the open gate - /// is the boundary; ADR-006) — the POC #1 wiring requirement + /// is the boundary; ADR-006); this is the POC #1 wiring requirement /// tested explicitly (the hook's `set_identity` is what made the /// allowed path work above; omitting it yields the same /// `FORBIDDEN`, fail-closed). #[tokio::test] async fn scopeless_identity_fails_closed_with_forbidden() { - // A bare identity — no scopes at all. + // A bare identity with no scopes at all. let bare = Identity { id: "scopeless".to_string(), scopes: vec![], @@ -1770,7 +1770,7 @@ mod tests { } /// The establisher's per-call auth witness: the opener identity - /// id arrives on the establisher's `AuthContext` (CF-006) — the + /// id arrives on the establisher's `AuthContext` (CF-006); the /// seam the producer task maps SOCKS5 credentials/ACL onto. #[tokio::test] async fn establisher_witnesses_the_opener_identity() { @@ -1796,7 +1796,7 @@ mod tests { /// The harness topology sanity: the producer's manager allocated /// the channel id (the serving side allocates; the initiator - /// adopts — ADR-047 §5). + /// adopts; ADR-047 §5). #[tokio::test] async fn producer_allocates_consumer_adopts() { let topo = wire(echo_dial()).await; @@ -1888,7 +1888,7 @@ mod tests { // Half-close teardown: client EOF ends the session; the // channel pump completes (the handler tracked the data plane - // to the end — R-02). + // to the end; R-02). drop(raw); tokio::time::timeout(std::time::Duration::from_secs(10), pump) .await @@ -1899,7 +1899,7 @@ mod tests { /// A refusing dial surfaces 0x05 in-stream with no phantom data /// plane: the reply comes back refused; bytes sent after the /// refusal are not echoed (nothing was dialed). The channel - /// stays healthy — a fresh open succeeds immediately. + /// stays healthy; a fresh open succeeds immediately. #[tokio::test] async fn producer_dial_refusal_is_0x05_and_no_phantom_data_plane() { let topo = wire(refusing_dial(SocksDialError::Refused("target down".into()))).await; @@ -1932,7 +1932,7 @@ mod tests { } /// The scope-class dial refusal (`NotAllowed`) surfaces 0x02 - /// (the mapped code — the dial seam's policy refusal path). + /// (the mapped code: the dial seam's policy refusal path). #[tokio::test] async fn producer_scope_class_refusal_is_0x02() { let topo = wire(refusing_dial(SocksDialError::NotAllowed("policy".into()))).await; @@ -1949,7 +1949,7 @@ mod tests { } /// ASSOCIATE refuses RFC-faithfully when the egress seam is not - /// wired (`CommandNotSupported` 0x07 — ADR-008's missing-callback + /// wired (`CommandNotSupported` 0x07, ADR-008's missing-callback /// class) while CONNECT still serves the same registration (the /// missing-callback posture, over channels). #[tokio::test] @@ -1987,7 +1987,7 @@ mod tests { // `datagram_frame`, `udp_unframe` is `decode_datagram`.) /// The ASSOCIATE conversation over a channel: open + adopt + - /// pump, greet, request, reply — asserting the sentinel + /// pump, greet, request, reply, asserting the sentinel /// `127.0.0.1:0` BND.ADDR (ADR-003's RFC-compatibility surface). async fn raw_associate( topo: &Topology, @@ -2091,7 +2091,7 @@ mod tests { .expect("read resolves"); match outcome { Ok(0) | Err(_) => {} - Ok(n) => panic!("EOF expected after an undecodable frame — got {n} bytes"), + Ok(n) => panic!("EOF expected after an undecodable frame; got {n} bytes"), } drop(raw); tokio::time::timeout(std::time::Duration::from_secs(10), pump) @@ -2147,7 +2147,7 @@ mod tests { } /// The egress-factory refusal maps to reply#1's mapped code over - /// channels (the pre-reply refusal path) — no phantom datagram + /// channels (the pre-reply refusal path); no phantom datagram /// plane. #[tokio::test] async fn producer_udp_associate_factory_refusal_maps_to_reply_code() { @@ -2182,7 +2182,7 @@ mod tests { } /// CONNECT + ASSOCIATE on the same registration over channels - /// (the regression guard, extended — one registration, both + /// (the regression guard, extended: one registration, both /// commands). #[tokio::test] async fn producer_connect_and_associate_on_the_same_registration() { @@ -2301,7 +2301,7 @@ mod tests { /// The command gate over channels (ADR-006; POC #5 test 4): an /// identity holding only `socks5:open` gets `ConnectionNotAllowed` - /// (0x02) on BIND — while CONNECT still serves the same identity + /// (0x02) on BIND, while CONNECT still serves the same identity /// (the gate distinguishes ingress from egress). #[tokio::test] async fn producer_bind_command_gate_refuses_0x02_connect_unaffected() { @@ -2317,7 +2317,7 @@ mod tests { ) .await; - // BIND refused, RFC-faithfully — no phantom listener (the + // BIND refused, RFC-faithfully, with no phantom listener (the // accept seam never ran; the session ends). let (mut raw, pump) = open_channel_and_pump(&topo).await; raw.greet().await.expect("greet"); @@ -2333,7 +2333,7 @@ mod tests { drop(pump); // CONNECT still works for the same identity (egress is not - // implied by the BIND gate's absence — separate scopes). + // implied by the BIND gate's absence; separate scopes). let (mut raw, _pump) = open_channel_and_pump(&topo).await; let code = raw .connect(&TargetAddr::Domain("echo.test".into(), 443)) @@ -2353,7 +2353,7 @@ mod tests { } /// The BIND-scope-only identity over channels: `socks5:bind` - /// alone serves BIND (the open gate is `socks5:open` — the + /// alone serves BIND (the open gate is `socks5:open`, so the /// registration's identity must carry it; the command gate only /// adds `socks5:bind` on top of a granted session). The both- /// scopes identity is the deployment shape that grants both @@ -2397,7 +2397,7 @@ mod tests { } /// Quiet-wait garbage over channels: a control byte after - /// reply#1 ends the session with no data plane — the raw client + /// reply#1 ends the session with no data plane: the raw client /// reads EOF (no reply#2, no echo), and the channel pump /// completes. #[tokio::test] @@ -2451,10 +2451,10 @@ mod tests { .expect("read resolves"); match outcome { Ok(0) | Err(_) => {} - Ok(n) => panic!("no reply#2 after garbage — got {n} bytes"), + Ok(n) => panic!("no reply#2 after garbage; got {n} bytes"), } // The client vanishes (EOF both ways through the raw pipe); - // the session pump ends (R-02 — the handler tracked the data + // the session pump ends (R-02: the handler tracked the data // plane to its end). drop(raw); tokio::time::timeout(std::time::Duration::from_secs(10), pump) @@ -2464,7 +2464,7 @@ mod tests { } /// The three-command regression guard (POC #5 test 7): one - /// registration, all three commands — CONNECT serves, BIND serves + /// registration, all three commands; CONNECT serves, BIND serves /// under its gate, ASSOCIATE serves with the echo egress. Fresh /// channel per session. #[tokio::test] diff --git a/tests/local-backend-producer.rs b/tests/local-backend-producer.rs index 4f2a7a7..8b43840 100644 --- a/tests/local-backend-producer.rs +++ b/tests/local-backend-producer.rs @@ -1,9 +1,9 @@ //! The producer-side local backend's integration tests (feature //! `local`; tasks/implementation/local-backend-producer.md): a vanilla -//! RFC 1928 client against the locally-served producer — the whole +//! RFC 1928 client against the locally-served producer: the whole //! stack on real sockets (`ProducerDoor` → `serve_socks5_over` → //! `local::tcp_dial()` / `local::bind_accept()`), BIND through the -//! local backend (the faithful-RFC variant — real reply#1/reply#2 +//! local backend (the faithful-RFC variant, with real reply#1/reply#2 //! addresses), the idle-first-byte timeout, the session cap, //! per-connection failure isolation, and the `bind_policy(false)` //! gate's RFC-faithful BIND refusal (0x02). @@ -24,7 +24,7 @@ const BOUND: Duration = Duration::from_secs(10); const IDLE: Duration = Duration::from_millis(300); /// Spawn a real echo TCP server on an ephemeral loopback port -/// (CONNECT's target — the dial seam dials it for real). +/// (CONNECT's target; the dial seam dials it for real). async fn echo_tcp_server() -> std::net::SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("local_addr"); @@ -146,7 +146,7 @@ async fn vanilla_client_connect_round_trips_through_local_seams() { _guard.close().await; } -/// BIND through the local backend — the faithful-RFC variant: a real +/// BIND through the local backend, the faithful-RFC variant: a real /// inbound peer connects to the reply#1-advertised listener, and /// reply#2 carries the real peer address. #[tokio::test] @@ -222,7 +222,7 @@ async fn vanilla_client_bind_round_trips_with_real_addresses() { /// The idle-first-byte defense (ADR-014 knob 1): an accepted /// connection that sends nothing within the window is closed -/// door-side — EOF arrives, no greeting ever answered. +/// door-side: EOF arrives, no greeting ever answered. #[tokio::test] async fn idle_first_byte_timeout_closes_the_accepted_connection() { let (door, _guard) = serve(local_producer(None), IDLE, 16).await; @@ -232,7 +232,7 @@ async fn idle_first_byte_timeout_closes_the_accepted_connection() { match timeout(IDLE * 4, stream.read(&mut buf)).await { Err(_) => panic!("the idle bound must fire"), Ok(Ok(0)) | Ok(Err(_)) => {} - Ok(Ok(n)) => panic!("EOF expected after the idle bound — got {n} bytes"), + Ok(Ok(n)) => panic!("EOF expected after the idle bound; got {n} bytes"), } _guard.close().await; } @@ -257,7 +257,7 @@ async fn session_cap_refuses_beyond_the_bound_while_live_sessions_serve() { match timeout(BOUND, refused.read(&mut buf)).await { Err(_) => panic!("the capped accept must end"), Ok(Ok(0)) | Ok(Err(_)) => {} - Ok(Ok(n)) => panic!("the capped accept is refused — got {n} bytes"), + Ok(Ok(n)) => panic!("the capped accept is refused; got {n} bytes"), } // The held session still serves (isolation: the refusal did not @@ -275,7 +275,7 @@ async fn session_cap_refuses_beyond_the_bound_while_live_sessions_serve() { } /// Per-connection failure isolation: a connection speaking protocol -/// garbage ends loud door-side while the loop continues — the next +/// garbage ends loud door-side while the loop continues; the next /// connection on the same door serves normally. #[tokio::test] async fn per_connection_failure_is_isolated_and_the_loop_continues() { @@ -312,15 +312,15 @@ async fn per_connection_failure_is_isolated_and_the_loop_continues() { } /// The `bind_policy(false)` gate is honored on this path (ADR-006's -/// posture): BIND refuses RFC-faithfully with 0x02 — the command is -/// known, the caller is not allowed — while CONNECT still serves. +/// posture): BIND refuses RFC-faithfully with 0x02 (the command is +/// known, the caller is not allowed), while CONNECT still serves. #[tokio::test] async fn bind_policy_false_refuses_bind_rfc_faithfully_but_connect_serves() { let target = echo_tcp_server().await; let (door, _guard) = serve(local_producer(Some(false)), IDLE, 16).await; // BIND → 0x02 (ConnectionNotAllowed), through the producer's own - // in-stream gate (no open-op identity here — the builder's + // in-stream gate (no open-op identity here; the builder's // bind_policy is the whole gate on this path). let mut client = TcpStream::connect(door).await.expect("door connect"); client @@ -438,7 +438,7 @@ async fn vanilla_client_udp_associate_relays_through_the_local_egress() { .expect("echo reply") .expect("read"); // The reply's inner carries the *sender's* address (the echo's - // source is the egress socket, not the target) — decode and match + // source is the egress socket, not the target), so decode and match // the payload only. let n = u16::from_be_bytes([reply[0], reply[1]]) as usize; let inner = &reply[2..2 + n]; @@ -456,7 +456,7 @@ async fn vanilla_client_udp_associate_relays_through_the_local_egress() { } /// The door's address target form: a domain-form CONNECT target -/// resolves at the seam (the ssh `-D` semantic — ADR-012) — the echo +/// resolves at the seam (the ssh `-D` semantic, ADR-012); the echo /// server is reachable as `localhost`. #[tokio::test] async fn domain_form_targets_resolve_at_the_seam() { diff --git a/tests/local-providers.rs b/tests/local-providers.rs index f4a9670..b1e6c74 100644 --- a/tests/local-providers.rs +++ b/tests/local-providers.rs @@ -1,7 +1,7 @@ //! The local providers' integration tests (feature `local`): the //! three socket-backed seams driven through the producer's real //! command-dispatch path (the harness's consumer-side topology) with -//! real targets — the CONNECT echo round-trip, the UDP datagram +//! real targets: the CONNECT echo round-trip, the UDP datagram //! round-trip through the producer's datagram stage (the POC #2 //! shape), the BIND accept end-to-end (real addresses in reply#1/ //! reply#2), the readiness contract re-pinned with the real-socket @@ -30,7 +30,7 @@ use harness::{both_scopes_identity, wire_consumer_side_with}; const BOUND: Duration = Duration::from_secs(10); /// A session error carrying an RFC reply code (the refusal probe's -/// expectation shape — the mapped code rides the session error). +/// expectation shape: the mapped code rides the session error). fn reply_code_of(err: &SocksSessionError) -> Option { match err { SocksSessionError::Rfc(SocksErrorKind::ReplyError(code)) => Some(code.as_u8()), @@ -38,7 +38,7 @@ fn reply_code_of(err: &SocksSessionError) -> Option { } } -/// Both managers hold only channel 0 (the no-phantom-channel probe — +/// Both managers hold only channel 0 (the no-phantom-channel probe; /// the connect suite's shape, re-pinned). async fn no_leaked_channels(topo: &harness::ConsumerSideTopology) { let deadline = Duration::from_secs(5); @@ -108,7 +108,7 @@ async fn echo_udp_server() -> SocketAddr { } /// The local providers wired as the producer's seams (the assembly -/// layer's shape — producer.md §Wiring's constructor names). +/// layer's shape; producer.md §Wiring's constructor names). fn local_providers() -> ( alksocks::egress::DialFn, alksocks::egress::EgressFactory, @@ -123,7 +123,7 @@ fn local_providers() -> ( /// CONNECT through the local dial: the consumer session's bytes /// round-trip through the real echo server; the domain target -/// (`localhost`) resolves producer-side (the ssh `-D` semantic — +/// (`localhost`) resolves producer-side (the ssh `-D` semantic, /// ADR-012); the dial witness saw it unresolved. #[tokio::test] async fn connect_round_trips_through_a_real_target() { @@ -160,7 +160,7 @@ async fn connect_round_trips_through_a_real_target() { /// A connect-refused target (a bound-then-dropped port): the dial /// seam refuses with `Refused` → the producer's reply carries 0x05 -/// → the session error surfaces the mapped code — the refusal rides +/// → the session error surfaces the mapped code (the refusal rides /// in-stream (no phantom channel). #[tokio::test] async fn connect_refused_surfaces_reply_code_0x05_through_the_producer() { @@ -188,7 +188,7 @@ async fn connect_refused_surfaces_reply_code_0x05_through_the_producer() { /// ASSOCIATE through the real-socket egress: the datagram stage's /// framed round-trip reaches a real UDP echo (the POC #2 shape -/// through the producer's stage — framing once, inner bytes at the +/// through the producer's stage: framing once, inner bytes at the /// egress, the sender's address reported back). #[tokio::test] async fn associate_round_trips_through_the_real_egress() { @@ -224,7 +224,7 @@ async fn associate_round_trips_through_the_real_egress() { /// The readiness contract re-pinned with the real-socket egress, /// through the producer's datagram stage: the association stays /// parked (the egress's `next_reply` parks on `readable()`) while no -/// datagram is in flight — the consumer's `send` after a quiet +/// datagram is in flight; the consumer's `send` after a quiet /// window still round-trips (no spin starved the channel branch; a /// hot `Ok(None)` egress would have wedged the stage's biased /// select into livelock and the second datagram would never @@ -241,7 +241,7 @@ async fn the_readiness_contract_holds_through_the_producer_stage() { let target = TargetAddr::Ip(echo); // A datagram now, then a quiet window (the stage parked on the - // real socket's readiness), then a datagram after — the second + // real socket's readiness), then a datagram after; the second // round-trip proves the stage never wedged. assoc .send_datagram(&target, b"first") @@ -347,7 +347,7 @@ async fn bind_end_to_end_with_real_addresses_and_round_trip() { assert_eq!(&buf, b"outbound"); // Both ends close (the producer's pump exits when the accepted - // halves' peer is gone — a real TCP stream stays open until its + // halves' peer is gone; a real TCP stream stays open until its // owner closes it, unlike the harness duplexes). drop(plane); drop(client); @@ -357,8 +357,8 @@ async fn bind_end_to_end_with_real_addresses_and_round_trip() { /// The accept-future-owns-its-listener contract, through the /// producer's quiet wait: a client-gone BIND session (the consumer -/// vanishes mid-quiet-wait) tears the listener down with the session -/// — the advertised address stops accepting (the drop-witness probe: +/// vanishes mid-quiet-wait) tears the listener down with the session; +/// the advertised address stops accepting (the drop-witness probe: /// a fresh re-bind of the same port succeeds, the listener was /// dropped, not leaked). #[tokio::test] @@ -383,13 +383,13 @@ async fn client_gone_mid_quiet_wait_closes_the_listener() { }; // The client-gone teardown: drop the session mid-quiet-wait (the - // channel's write half EOFs — the producer's quiet wait exits on + // channel's write half EOFs; the producer's quiet wait exits on // the EOF sentinel and the accept future drops, taking the // listener with it). drop(session); // The producer's teardown reaps the channel entry (poll past the - // reap race), then the advertised address must be re-bindable — + // reap race), then the advertised address must be re-bindable; // the listener closed with the quiet wait's drop. timeout(Duration::from_secs(5), async { loop { @@ -408,7 +408,7 @@ async fn client_gone_mid_quiet_wait_closes_the_listener() { .expect("the producer's entry reaped"); // A held port would refuse the re-bind; the dropped listener - // frees it. (The OS may hand the port to the re-bind — the probe + // frees it. (The OS may hand the port to the re-bind; the probe // is that it is free either way.) let mut rebound = None; for _ in 0..50 { diff --git a/tests/rfc1928-differential.rs b/tests/rfc1928-differential.rs index 5da24cf..6918fb7 100644 --- a/tests/rfc1928-differential.rs +++ b/tests/rfc1928-differential.rs @@ -1,9 +1,9 @@ //! The RFC 1928 core's differential RFC edge-case suite (the wire -//! form is the point — expectations are pinned as explicit bytes). +//! form is the point; expectations are pinned as explicit bytes). //! //! Posture (docs/architecture/rfc1928.md §Maintenance Posture, //! ADR-013): `/workspace/fast-socks5` (v1.0.0) is the behavioral -//! reference — consulted while writing these tests; the assertions +//! reference, consulted while writing these tests; the assertions //! pin the expected bytes, never the reference checkout itself //! (build-time dependency-free). Where the fork deliberately //! diverges (the pruned error variants, the published `new_reply`), @@ -13,7 +13,7 @@ //! Differential convention: every non-obvious expectation names the //! upstream behavior it pins (file + line in the v1.0.0 checkout). //! -//! Infrastructure: tokio duplex streams only — no sockets, no +//! Infrastructure: tokio duplex streams only (no sockets, no //! `local` feature; the suite runs in the default wasm-clean crate's //! native test build. @@ -40,7 +40,7 @@ fn spawn_no_auth(server: tokio::io::DuplexStream) -> ServerTask<()> { } // --------------------------------------------------------------------------- -// Group 1 — negotiation edge cases +// Group 1: negotiation edge cases // --------------------------------------------------------------------------- /// An empty method list (methods_len = 0) matches nothing → 0xff. @@ -84,7 +84,7 @@ async fn negotiation_zero_methods_offered_replies_0xff() { /// Method-order preference: the server picks *its* first match in /// `server_methods` order, not the client's first offer. Upstream /// pins this as the outer-loop-in-server-order contract -/// (`src/server.rs:901-902` — "server_methods order matter!") — +/// (`src/server.rs:901-902`, "server_methods order matter!"); /// in-tree, `accept_no_auth` offers only `NoAuthentication`, so a /// client offering password-first-then-none still gets 0x00. #[tokio::test] @@ -100,7 +100,7 @@ async fn negotiation_server_picks_its_own_first_match() { /// The same contract under `accept_password_auth`: a client offering /// none-then-password is answered with the *server's* preferred -/// method (0x02 — `negotiate_auth` walks `server_methods` in server +/// method (0x02; `negotiate_auth` walks `server_methods` in server /// order; RFC 1928 §3 lets the server pick by its own preference, so /// the method list is ordered more-secure/preferred first). #[tokio::test] @@ -128,7 +128,7 @@ async fn negotiation_password_server_prefers_password_over_no_auth() { /// Version mismatch at the greeting (0x04) → typed refusal, no hang. /// Upstream: `negotiate_auth` checks `version` immediately after /// reading the 2-byte header, before any method bytes -/// (`src/server.rs:890-892`) — so a truncated greeting (no methods +/// (`src/server.rs:890-892`); so a truncated greeting (no methods /// byte) still refuses on the version alone. #[tokio::test] async fn negotiation_version_mismatch_refuses_before_methods_byte() { @@ -224,11 +224,11 @@ async fn password_auth_reject_writes_0x01_0xff_then_errors() { } // --------------------------------------------------------------------------- -// Group 2 — command-read edge cases +// Group 2: command-read edge cases // --------------------------------------------------------------------------- /// Unknown command byte (0x04+) → `UnknownCommand` → 0x07 reply. The -/// address is parsed *first* (upstream `src/server.rs:1018` — the +/// address is parsed *first* (upstream `src/server.rs:1018`: the /// try_notify order), so the request must carry a valid target for /// the reply to fire; the reply uses the error form (ATYP=1, zeros). #[tokio::test] @@ -255,7 +255,7 @@ async fn command_unknown_byte_replies_0x07() { } /// Reserved-byte tolerance: RSV ≠ 0 is read but never checked by -/// upstream (`src/server.rs:1001-1002` — destructured as `rsv`, only +/// upstream (`src/server.rs:1001-1002`, destructured as `rsv`, only /// debug-logged; no validation). Pin the tolerance: a garbage RSV /// byte still parses to a successful command read. #[tokio::test] @@ -340,9 +340,9 @@ async fn command_atyp_0x05_replies_0x08() { /// Domain-length boundary: a 255-byte domain is the wire maximum and /// parses fine (`read_address` reads a 1-byte len then that many -/// bytes — `src/util/target_addr.rs:236-245`; nothing else bounds +/// bytes (`src/util/target_addr.rs:236-245`; nothing else bounds /// it). 256+ is rejected at *encode* time by `to_be_bytes` -/// (`src/util/target_addr.rs:113-115`) — parsing never sees it. +/// (`src/util/target_addr.rs:113-115`), so parsing never sees it. #[tokio::test] async fn command_domain_255_bytes_is_the_wire_maximum() { let (mut client, server) = tokio::io::duplex(64); @@ -363,13 +363,13 @@ async fn command_domain_255_bytes_is_the_wire_maximum() { assert_eq!(target, TargetAddr::Domain("a".repeat(255), 443)); } -/// The decode-side bound is the wire's own 1-byte length field — +/// The decode-side bound is the wire's own 1-byte length field, /// stated explicitly (review 001 S-1): `read_address`'s domain arm /// allocates from an attacker-controlled byte, and the allocation is /// safe *only* because the length field is one byte (≤ 255). This /// test pins both faces of that invariant: (a) the 255-byte maximum /// decodes through `read_address` (the attacker-side path), and (b) -/// the encoder rejects a 256-byte claim — a 256-byte domain cannot +/// the encoder rejects a 256-byte claim (a 256-byte domain cannot /// enter the wire at all, so the decode arm's `len as usize` can /// never ask for more than 255. Widening the length field (a /// hypothetical FRAG-style extension) requires an explicit cap @@ -399,7 +399,7 @@ async fn decode_side_domain_allocation_is_bounded_by_the_1_byte_wire_field() { /// Truncated address fields (EOF mid-address) → typed /// `AddrError`-wrapped error, surfaced through `try_notify!`'s /// 0x01-coded secondary reply (AddrError non-ATYP variants map to -/// `ConnectionRefused` — `src/util/target_addr.rs:40-48`). +/// `ConnectionRefused`; `src/util/target_addr.rs:40-48`). #[tokio::test] async fn command_truncated_ipv4_address_is_typed_error_with_0x05_reply() { let (mut client, server) = tokio::io::duplex(64); @@ -453,7 +453,7 @@ async fn command_truncated_domain_content_is_typed_error() { /// Version mismatch at the command phase (post-negotiation, the /// 4-byte request header read) → typed refusal, no reply. Upstream /// `read_command` checks `version` before parsing the address -/// (`src/server.rs:1010-1014`) — a distinct check from the greeting's +/// (`src/server.rs:1010-1014`), a distinct check from the greeting's /// (`negotiate_auth`, `src/server.rs:889-892`), so this exercises the /// command-phase arm in isolation. #[tokio::test] @@ -478,7 +478,7 @@ async fn command_version_mismatch_refuses_before_address_parse() { } // --------------------------------------------------------------------------- -// Group 3 — reply-form edge cases (the `new_reply` carried change's +// Group 3: reply-form edge cases (the `new_reply` carried change's // regression guard) // --------------------------------------------------------------------------- @@ -564,7 +564,7 @@ async fn reply_error_form_carries_zero_addr() { } /// Success replies carry the caller's `sock_addr` verbatim *on the -/// wire* — `reply_success` does not substitute its own address +/// wire*; `reply_success` does not substitute its own address /// (upstream `src/server.rs:931-941`; the 127.0.0.1 substitution is /// the `run_tcp_proxy` caller's choice, `src/server.rs:1087`, not /// the typestate's). @@ -593,11 +593,11 @@ async fn reply_success_carries_caller_sock_addr_on_the_wire() { } // --------------------------------------------------------------------------- -// Group 4 — client-session edge cases +// Group 4: client-session edge cases // --------------------------------------------------------------------------- /// Reply#1 with a non-zero reply code surfaces as the mapped -/// `ReplyError` (the `from_u8` table — upstream +/// `ReplyError` (the `from_u8` table; upstream /// `src/client.rs:363`). Covered per-code including the 0x06 double /// mapping (ConnectionTimeout and TtlExpired both carry 0x06). #[tokio::test] @@ -713,7 +713,7 @@ async fn client_domain_request_encoding_is_len_prefixed() { /// for non-ASSOCIATE commands was /// `anyhow::Error::msg("target addr should be present")` /// (`src/client.rs:285`); in-tree it is the typed -/// `SocksError::MissingTargetAddr` (rfc1928.md's adjustment list) — +/// `SocksError::MissingTargetAddr` (rfc1928.md's adjustment list), /// pinned by the wire-vocabulary module's own test /// (`mod.rs` tests: `socks_error_from_variants` keeps the variant /// reachable; the private-API form of this test lives in @@ -729,14 +729,14 @@ async fn client_udp_associate_zero_target_fallback_is_10_zero_bytes() { let parsed = alksocks::rfc1928::util::target_addr::read_address(&mut addr_field, 0x01) .await .unwrap(); - // The zero form decodes to the unspecified IPv4 addr, port 0 — + // The zero form decodes to the unspecified IPv4 addr, port 0; // the wire meaning of the fallback. assert_eq!(parsed.to_string(), "0.0.0.0:0"); assert_eq!(expected.len(), 10); } /// The client's v6 request encoding: ATYP=4, the full 16-byte -/// address, the 2-byte port, 22 bytes total — the `packet[4..20]`/ +/// address, the 2-byte port, 22 bytes total: the `packet[4..20]`/ /// `packet[20..22]` slice boundaries pinned byte-for-byte. Upstream /// `request_header`'s v6 arm (`src/client.rs:293-300`); the in-tree /// arm is byte-identical (`client.rs`, `TargetAddr::Ip @@ -780,7 +780,7 @@ async fn client_v6_request_encoding_is_22_bytes_with_atyp_4() { /// `Socks5Stream`'s `AsyncWrite::poll_shutdown` passthrough reaches /// the backing stream: `shutdown()` closes the write side and the -/// mock observes EOF — the half-close propagation shape the consumer +/// mock observes EOF, the half-close propagation shape the consumer /// half's two-pump teardown relies on (the EOF-sentinel convention). /// The passthrough is a 3-line delegate (`client.rs` impl block); /// without this test a regression in the `poll_shutdown` arm (e.g. @@ -802,7 +802,7 @@ async fn client_session_shutdown_propagates_to_the_backing_stream() { .await?; session.flush().await?; session.shutdown().await?; - // After the shutdown the write side is closed — a further + // After the shutdown the write side is closed; a further // write surfaces `BrokenPipe` (the passthrough's close // semantics, tokio `DuplexStream`'s closed-writer shape). match session.write_all(b"late").await { @@ -863,7 +863,7 @@ async fn client_skip_auth_emits_no_greeting() { } // --------------------------------------------------------------------------- -// Group 5 — UDP codec edge cases +// Group 5: UDP codec edge cases // --------------------------------------------------------------------------- /// `parse_udp_request` with garbage RSV → `GarbageInReserved`. The @@ -880,7 +880,7 @@ async fn udp_codec_garbage_reserved_is_rejected() { )); } -/// FRAG ≠ 0 passes *through* the parser — the parser's contract is +/// FRAG ≠ 0 passes *through* the parser; the parser's contract is /// to return the frag byte; refusing fragmentation is the *caller's* /// job (upstream discards frag≠0 packets in `handle_udp_request`, /// `src/server.rs:1236-1242`, not in `parse_udp_request`). Pin the @@ -913,7 +913,7 @@ async fn udp_codec_round_trip_ipv6() { } /// The UDP header round-trip preserves a non-empty payload split -/// (header + data) — the wire frame is RSV+FRAG+target-bytes and the +/// (header + data): the wire frame is RSV+FRAG+target-bytes and the /// parser returns the remainder as payload. Upstream's payload split /// is the returned `req` slice (`src/lib.rs:342`). #[tokio::test] @@ -928,7 +928,7 @@ async fn udp_codec_round_trip_carries_payload() { } // --------------------------------------------------------------------------- -// Group 6 — `transfer`/`wait_on_tcp` semantics +// Group 6: `transfer`/`wait_on_tcp` semantics // --------------------------------------------------------------------------- /// `transfer` pumps both directions to completion. Upstream is a @@ -972,7 +972,7 @@ async fn wait_on_tcp_returns_eof_on_clean_close() { } /// `wait_on_tcp` returns `UnexpectedUdpControlGarbage` on a control -/// byte — the received byte is carried in the error. Upstream +/// byte; the received byte is carried in the error. Upstream /// `src/server.rs:1202`. #[tokio::test] async fn wait_on_tcp_carries_control_garbage_byte() { @@ -990,10 +990,10 @@ async fn wait_on_tcp_carries_control_garbage_byte() { /// stream" context. Upstream `src/server.rs:1203` /// (`Err(err) => Err(err).err_when("waiting on UDP control stream")`). /// Driven by a real error producer: writing to a duplex whose peer is -/// gone surfaces `BrokenPipe` on the *write* side — for the read arm +/// gone surfaces `BrokenPipe` on the *write* side; for the read arm /// the harness swaps in a failing reader so the `Err(err)` shape is /// exercised deterministically (a dropped-peer duplex read is a clean -/// EOF, `Ok(0)`, which is `SocksServerError::EOF` — the other arm). +/// EOF, `Ok(0)`, which is `SocksServerError::EOF` (the other arm). #[tokio::test] async fn wait_on_tcp_wraps_io_errors_with_context() { struct FailingReader;