Skip to main content

mkit_cli/commands/serve/
mod.rs

1//! `mkit serve <path>` — speak the mkit-rpc SSH protocol on
2//! stdin/stdout against a local repository.
3//!
4//! The backing repo is accessed via `FileTransport`. Frames are
5//! length-prefixed protobuf [`SshFrame`] messages defined in
6//! `rust/crates/mkit-rpc/proto/mkit/rpc/v1/ssh/ssh.proto` (buffa is the Rust
7//! runtime; the wire is protobuf 3 / edition 2023).
8
9use std::io::{Read, Write};
10use std::path::PathBuf;
11
12use clap::Parser;
13use mkit_core::hash::hash;
14use mkit_core::protocol::{PackKey, RefWriteCondition, Transport, TransportError};
15use mkit_rpc::mkit::common::v1::{RefEntry, RefExpectation};
16use mkit_rpc::mkit::rpc::v1::ssh::{
17    DownloadPackHeader, HelloResponse, ListRefsResponse, PackChunk, PackExistsResponse,
18    ReadRefResponse, SshFrame, UploadPack, UploadPackResponse, ssh_frame,
19};
20use mkit_rpc::mkit::rpc::v1::{ErrorCode, ProtocolVersion};
21use mkit_rpc::{FrameError, read_frame, write_frame};
22use mkit_transport_file::FileTransport;
23
24use crate::clap_shim;
25use crate::cli::CLI_VERSION;
26use crate::exit;
27
28#[derive(Debug, Parser)]
29#[command(
30    name = "mkit serve",
31    about = "Speak the mkit-rpc protocol on stdin/stdout (default) or on \
32             an encrypted TCP socket (--listen-enc)."
33)]
34struct ServeOpts {
35    /// Path to the repository to serve.
36    path: String,
37    /// Listen for incoming encrypted-stream connections on `addr`
38    /// (e.g. `0.0.0.0:9418` or `127.0.0.1:7777`) instead of speaking
39    /// the SSH-frame protocol on stdin/stdout. Requires the
40    /// `enc-transport` cargo feature. See SPEC-TRANSPORT-ENC §6 item 4
41    /// (issue #156).
42    ///
43    /// FAIL-CLOSED: the listener refuses to bind unless either
44    /// `--enc-authorized-peers <PATH>` is supplied (an allowlist of
45    /// client public keys) or `--unsafe-allow-any-enc-peer` is passed.
46    /// Server identity is loaded from `--enc-server-key <PATH>` (a
47    /// user-scoped raw 32-byte key file) so clients can pin
48    /// `?pubkey=<…>` across restarts; with the unsafe flag and no key
49    /// file an ephemeral per-process key is generated instead.
50    #[arg(long = "listen-enc", value_name = "ADDR")]
51    listen_enc: Option<String>,
52
53    /// Path to an allowlist of authorized client public keys, one per
54    /// line (64-hex or 43-char url-safe base64; `#` comments and blank
55    /// lines ignored). A client whose static ed25519 key is not listed
56    /// is rejected at the handshake and never receives any data.
57    ///
58    /// MUST be a CLI-supplied or user-scoped path — peer-authorization
59    /// is NEVER read from repo-local `.mkit/config`.
60    #[arg(long = "enc-authorized-peers", value_name = "PATH")]
61    enc_authorized_peers: Option<String>,
62
63    /// Path to the server's stable raw 32-byte ed25519 key file. When
64    /// allowlisting, this is auto-created at a user-scoped default path
65    /// if omitted so the advertised `?pubkey=` is stable across
66    /// restarts. User-scoped/CLI-only; never repo-local.
67    #[arg(long = "enc-server-key", value_name = "PATH")]
68    enc_server_key: Option<String>,
69
70    /// Dev/test escape hatch: accept ANY encrypted peer (fail-open).
71    /// Prints a loud warning. Intended only for local development and
72    /// the direct-listen e2e harness — NEVER for production.
73    #[arg(long = "unsafe-allow-any-enc-peer", default_value_t = false)]
74    unsafe_allow_any_enc_peer: bool,
75
76    /// Post-handshake per-frame idle timeout, in seconds, for the
77    /// encrypted listener (#216). After the handshake completes, a peer
78    /// that does not send the next verb/upload frame within this window
79    /// has its session dropped — preventing a slow-loris peer from
80    /// pinning a worker + socket forever. `0` disables the timeout
81    /// (NOT recommended). Default: 60s.
82    #[arg(
83        long = "enc-idle-timeout-secs",
84        value_name = "SECS",
85        default_value_t = 60
86    )]
87    enc_idle_timeout_secs: u64,
88
89    /// Handshake completion deadline, in seconds, for the encrypted
90    /// listener (#216). SPEC-TRANSPORT-ENC §6.2 recommends tightening to
91    /// ≤5–10s on real networks; the default is deliberately generous.
92    /// Default: 60s.
93    #[arg(
94        long = "enc-handshake-timeout-secs",
95        value_name = "SECS",
96        default_value_t = 60
97    )]
98    enc_handshake_timeout_secs: u64,
99
100    /// Host `mkit.transport.v1.TransportService` (SPEC-TRANSPORT-CONNECT)
101    /// over axum/HTTP on `addr` (e.g. `0.0.0.0:8443` or `127.0.0.1:7777`),
102    /// instead of speaking the SSH-frame protocol on stdin/stdout. Requires
103    /// the `http-transport` cargo feature. This is the self-hosted
104    /// `mkit+https://` remote (issue #700) — put a reverse proxy in front
105    /// for TLS in production; this listener speaks plaintext HTTP.
106    ///
107    /// FAIL-CLOSED, mirroring `--listen-enc`: refuses to bind unless
108    /// either a bearer token is configured (`--http-token` or the
109    /// `MKIT_API_TOKEN` env var — the same variable
110    /// `mkit-transport-http`'s client already sends, SPEC-TRANSPORT §5.2)
111    /// or `--unsafe-allow-any-http-peer` is passed.
112    #[arg(long = "http", value_name = "ADDR")]
113    http: Option<String>,
114
115    /// Bearer token required on every RPC's `Authorization: Bearer <token>`
116    /// header when `--http` is used. Falls back to the `MKIT_API_TOKEN`
117    /// environment variable when omitted. CLI-only/env-only — never read
118    /// from repo-local `.mkit/config`, matching the encrypted listener's
119    /// peer-authorization sourcing.
120    #[arg(long = "http-token", value_name = "TOKEN")]
121    http_token: Option<String>,
122
123    /// Dev/test escape hatch: accept ANY caller on `--http` with no bearer
124    /// check (fail-open). Prints a loud warning. Intended only for local
125    /// development — NEVER for production, since every RPC (including ref
126    /// writes and pack uploads) is unauthenticated.
127    #[arg(long = "unsafe-allow-any-http-peer", default_value_t = false)]
128    unsafe_allow_any_http_peer: bool,
129}
130
131// -- Per-connection resource caps -------------------------------------------
132//
133// A single `mkit serve` invocation is driven by a remote client via an SSH
134// forced command. Bounding cumulative work prevents a misbehaving or
135// malicious client from pinning the sshd-spawned process indefinitely.
136pub(crate) const MAX_FRAMES_PER_CONN: u32 = 10_000;
137pub(crate) const MAX_BYTES_PER_CONN: u64 = 1024 * 1024 * 1024; // 1 GiB
138
139/// Pack chunk size cap during downloads. Keeps each `PackChunk` frame
140/// well below the `MAX_FRAME_BYTES` (1 MiB) limit imposed by mkit-rpc's
141/// length-prefixed framing.
142const PACK_CHUNK_DATA_MAX: usize = 800 * 1024;
143
144#[must_use]
145pub fn run(args: &[String]) -> u8 {
146    let opts = match clap_shim::parse::<ServeOpts>("mkit serve", args) {
147        Ok(o) => o,
148        Err(code) => return code,
149    };
150
151    let repo_root = match resolve_repo_path(&opts.path) {
152        Ok(p) => p,
153        Err(code) => return code,
154    };
155
156    if let Some(addr) = opts.listen_enc.as_deref() {
157        if opts.http.is_some() {
158            eprintln!("mkit serve: --listen-enc and --http are mutually exclusive");
159            return exit::USAGE;
160        }
161        return run_listen_enc(
162            addr,
163            repo_root,
164            opts.enc_authorized_peers.as_deref(),
165            opts.enc_server_key.as_deref(),
166            opts.unsafe_allow_any_enc_peer,
167            opts.enc_idle_timeout_secs,
168            opts.enc_handshake_timeout_secs,
169        );
170    }
171
172    if let Some(addr) = opts.http.as_deref() {
173        return http::run_listen_http(
174            addr,
175            repo_root,
176            opts.http_token.as_deref(),
177            opts.unsafe_allow_any_http_peer,
178        );
179    }
180
181    let tx = FileTransport::new(&repo_root);
182    let stdin = std::io::stdin();
183    let stdout = std::io::stdout();
184    let mut r = stdin.lock();
185    let mut w = stdout.lock();
186
187    serve_loop(&tx, &mut r, &mut w)
188}
189
190mod enc;
191mod http;
192#[cfg(feature = "sparse-checkout")]
193mod sparse;
194
195// Submodule re-exports kept on the parent surface.
196use enc::run_listen_enc;
197// Re-exported so the parent module's test suite can drive the encrypted
198// listener helpers directly.
199#[cfg(all(test, feature = "enc-transport"))]
200use enc::{load_authorized_peers, serve_enc_session};
201// `#[doc(hidden)]` reference infra for issue #158 (no shipping verb yet);
202// re-exported so it stays reachable (not dead code) without advertising it
203// on the public API surface.
204#[cfg(feature = "sparse-checkout")]
205#[doc(hidden)]
206pub use sparse::{SparseServeError, build_sparse_response_from_tree};
207
208/// Resolve and validate the on-disk path supplied to `mkit serve`.
209pub(crate) fn resolve_repo_path(path: &str) -> Result<PathBuf, u8> {
210    let resolved = std::fs::canonicalize(path).map_err(|_| exit::NOINPUT)?;
211    if !resolved.is_dir() {
212        return Err(exit::DATAERR);
213    }
214    if !resolved.join(".mkit").is_dir() {
215        return Err(exit::DATAERR);
216    }
217    if let Ok(root) = std::env::var("MKIT_SERVE_ROOT") {
218        let pinned = std::fs::canonicalize(&root).map_err(|_| exit::NOPERM)?;
219        if !resolved.starts_with(&pinned) {
220            return Err(exit::NOPERM);
221        }
222    }
223    Ok(resolved)
224}
225
226/// Core serve loop, generic over reader/writer so tests can drive it
227/// with synthetic streams.
228pub(crate) fn serve_loop(tx: &FileTransport, r: &mut impl Read, w: &mut impl Write) -> u8 {
229    if !handshake(r, w) {
230        return exit::PROTOCOL_ERROR;
231    }
232
233    // Test-only fault injection for the mkit#703 SSH retry regression
234    // test (`tests/ssh_retry_e2e.rs`): return immediately after a
235    // successful `Hello`/`HelloResponse`, before answering any verb,
236    // so the process exits and the child pipe closes — simulating a
237    // mid-session connection drop that the client's `SshTransport`
238    // retry/reconnect path (SPEC-TRANSPORT §7) must recover from. A
239    // no-op — and never read — unless the hermetic harness explicitly
240    // sets this env var; production `mkit serve` never sets it.
241    if std::env::var_os("MKIT_SERVE_TEST_DIE_AFTER_HELLO").is_some() {
242        return exit::OK;
243    }
244
245    let mut frame_count: u32 = 0;
246    let mut byte_count: u64 = 0;
247
248    loop {
249        let frame: SshFrame = match read_frame(r) {
250            Ok(f) => f,
251            Err(FrameError::LengthTruncated) => return exit::OK,
252            Err(_) => {
253                let _ = emit_error(w, ErrorCode::InvalidRequest, "frame parse error");
254                return exit::PROTOCOL_ERROR;
255            }
256        };
257
258        frame_count = frame_count.saturating_add(1);
259        if frame_count > MAX_FRAMES_PER_CONN {
260            let _ = emit_error(
261                w,
262                ErrorCode::InvalidRequest,
263                "per-connection frame budget exceeded",
264            );
265            return exit::PROTOCOL_ERROR;
266        }
267
268        // Approximate per-frame byte cost using the encoded length
269        // we just consumed. We do not have the wire bytes here, but
270        // the request payload sizes inside the frame body are a
271        // close enough proxy for budget tracking.
272        byte_count = byte_count.saturating_add(frame_byte_estimate(&frame));
273        if byte_count > MAX_BYTES_PER_CONN {
274            let _ = emit_error(
275                w,
276                ErrorCode::InvalidRequest,
277                "per-connection byte budget exceeded",
278            );
279            return exit::PROTOCOL_ERROR;
280        }
281
282        match frame.body {
283            Some(ssh_frame::Body::Close(_)) => return exit::OK,
284            body => {
285                if dispatch(tx, body, w, r).is_err() {
286                    return exit::OK;
287                }
288            }
289        }
290    }
291}
292
293fn handshake(r: &mut impl Read, w: &mut impl Write) -> bool {
294    let frame: SshFrame = match read_frame(r) {
295        Ok(f) => f,
296        Err(_) => return false,
297    };
298    let Some(ssh_frame::Body::Hello(hello)) = frame.body else {
299        let _ = emit_error(w, ErrorCode::InvalidRequest, "first frame must be Hello");
300        return false;
301    };
302    let proto = hello.proto.unwrap_or_default();
303    if proto != ProtocolVersion::ProtocolVersion1 {
304        let _ = emit_error(
305            w,
306            ErrorCode::InvalidRequest,
307            &format!("unsupported proto_version {}", proto.to_i32()),
308        );
309        return false;
310    }
311    let resp = SshFrame {
312        body: Some(ssh_frame::Body::HelloResponse(Box::new(HelloResponse {
313            proto: Some(ProtocolVersion::ProtocolVersion1.into()),
314            server_id: Some(format!("mkit serve/{CLI_VERSION}")),
315            ..Default::default()
316        }))),
317        ..Default::default()
318    };
319    write_frame(w, &resp).is_ok()
320}
321
322fn dispatch(
323    tx: &FileTransport,
324    body: Option<ssh_frame::Body>,
325    w: &mut impl Write,
326    r: &mut impl Read,
327) -> std::io::Result<()> {
328    let Some(body) = body else {
329        return emit_error(w, ErrorCode::InvalidRequest, "empty frame");
330    };
331
332    // Streaming and protocol-control verbs are handled here because they
333    // span multiple frames; everything else routes through the shared
334    // sans-IO `handle_simple_verb`.
335    match &body {
336        ssh_frame::Body::DownloadPack(req) => {
337            let key = match pack_key_from_id(req.pack_id.as_ref()) {
338                Ok(k) => k,
339                Err((code, msg)) => return emit_error(w, code, msg),
340            };
341            match tx.download_pack(&key) {
342                Ok(bytes) => {
343                    send(
344                        w,
345                        ssh_frame::Body::DownloadPackHeader(Box::new(DownloadPackHeader {
346                            total_bytes: Some(bytes.len() as u64),
347                            ..Default::default()
348                        })),
349                    )?;
350                    for chunk in download_chunks(req.pack_id.clone(), &bytes) {
351                        send(w, ssh_frame::Body::PackChunk(Box::new(chunk)))?;
352                    }
353                    Ok(())
354                }
355                Err(_) => emit_error(w, ErrorCode::KeyNotFound, "pack not found"),
356            }
357        }
358        ssh_frame::Body::UploadPack(header) => {
359            let mut upload = match UploadDrain::new(header) {
360                Ok(upload) => upload,
361                Err(e) => return emit_error(w, ErrorCode::InvalidRequest, e.message()),
362            };
363            loop {
364                let frame: SshFrame = match read_frame(r) {
365                    Ok(f) => f,
366                    Err(_) => {
367                        return emit_error(w, ErrorCode::InvalidRequest, "pack chunk read failed");
368                    }
369                };
370                let Some(ssh_frame::Body::PackChunk(chunk)) = frame.body else {
371                    return emit_error(
372                        w,
373                        ErrorCode::InvalidRequest,
374                        "expected PackChunk after UploadPack",
375                    );
376                };
377                let complete = match upload.push_chunk(&chunk) {
378                    Ok(complete) => complete,
379                    Err(e) => return emit_error(w, ErrorCode::InvalidRequest, e.message()),
380                };
381                if complete {
382                    break;
383                }
384            }
385            let (bytes, key) = upload.into_parts();
386            match tx.upload_pack(&bytes, &key) {
387                Ok(()) => send(
388                    w,
389                    ssh_frame::Body::UploadPackResponse(Box::new(UploadPackResponse {
390                        ..Default::default()
391                    })),
392                ),
393                Err(_) => emit_error(w, ErrorCode::Internal, "upload failed"),
394            }
395        }
396        ssh_frame::Body::PackChunk(_) => emit_error(
397            w,
398            ErrorCode::InvalidRequest,
399            "PackChunk arrived without UploadPack header",
400        ),
401        ssh_frame::Body::Hello(_) => {
402            emit_error(w, ErrorCode::InvalidRequest, "Hello after handshake")
403        }
404        other => match handle_simple_verb(tx, other) {
405            Some(Ok(resp)) => send(w, resp),
406            Some(Err((code, msg))) => emit_error(w, code, msg),
407            None => emit_error(w, ErrorCode::InvalidRequest, "unexpected request frame"),
408        },
409    }
410}
411
412fn send(w: &mut impl Write, body: ssh_frame::Body) -> std::io::Result<()> {
413    let frame = SshFrame {
414        body: Some(body),
415        ..Default::default()
416    };
417    write_frame(w, &frame).map_err(|_| std::io::Error::other("frame write"))
418}
419
420// ---------------------------------------------------------------------------
421// Transport-generic verb decoding (shared by the sync stdin/stdout server and
422// the async encrypted listener).
423//
424// These helpers are pure: they decode a request frame into either a response
425// `ssh_frame::Body` or a `(ErrorCode, message)` protocol error, with no I/O.
426// Both dispatchers route every non-streaming verb through `handle_simple_verb`
427// and share the download chunking / upload-CAS logic below, so the two servers
428// cannot drift on length checks, the `RefExpectation` -> `RefWriteCondition`
429// mapping, or the per-frame chunk cap.
430// ---------------------------------------------------------------------------
431
432/// A protocol-level rejection: an `ErrorCode` plus a static message. The
433/// transport layer turns this into an `ssh_error_frame`.
434type VerbError = (ErrorCode, &'static str);
435
436/// Decode a 32-byte pack id into a [`PackKey`], rejecting wrong lengths.
437fn pack_key_from_id(bytes: Option<&Vec<u8>>) -> Result<PackKey, VerbError> {
438    let b = bytes.ok_or((ErrorCode::InvalidRequest, "pack_id missing"))?;
439    if b.len() != 32 {
440        return Err((ErrorCode::InvalidRequest, "pack_id must be 32 bytes"));
441    }
442    let mut h = [0u8; 32];
443    h.copy_from_slice(b);
444    Ok(PackKey(h))
445}
446
447/// Decode an `UpdateRef` request into `(name, new_hash, condition)`,
448/// applying the CAS rules shared by both servers. `expected_id` is only
449/// consulted for `MATCH` and MUST be a 32-byte digest. See
450/// SPEC-TRANSPORT §4.2.1.
451fn decode_update_ref(
452    req: &mkit_rpc::mkit::rpc::v1::ssh::UpdateRef,
453) -> Result<(String, [u8; 32], RefWriteCondition), VerbError> {
454    let name = req.name.clone().unwrap_or_default();
455    let new_id = req.new_id.clone().unwrap_or_default();
456    if new_id.len() != 32 {
457        return Err((ErrorCode::InvalidRequest, "new_id must be 32 bytes"));
458    }
459    let mut new_h = [0u8; 32];
460    new_h.copy_from_slice(&new_id);
461    let expectation = req
462        .expectation
463        .as_ref()
464        .and_then(buffa::EnumValue::as_known)
465        .unwrap_or(RefExpectation::Unspecified);
466    let condition = match expectation {
467        RefExpectation::Any => RefWriteCondition::Any,
468        RefExpectation::Missing => RefWriteCondition::Missing,
469        RefExpectation::Match => {
470            let bytes = req.expected_id.as_deref().unwrap_or(&[]);
471            if bytes.len() != 32 {
472                return Err((
473                    ErrorCode::InvalidRequest,
474                    "MATCH expectation requires a 32-byte expected_id",
475                ));
476            }
477            let mut e = [0u8; 32];
478            e.copy_from_slice(bytes);
479            RefWriteCondition::Match(e)
480        }
481        RefExpectation::Unspecified => {
482            return Err((
483                ErrorCode::InvalidRequest,
484                "UpdateRef.expectation is required",
485            ));
486        }
487    };
488    Ok((name, new_h, condition))
489}
490
491/// Build the ordered list of `PackChunk` bodies for a download. An empty
492/// pack still produces a single `last=true` chunk so the client always
493/// sees a terminator.
494#[allow(clippy::cast_possible_truncation)]
495fn download_chunks(pack_id: Option<Vec<u8>>, bytes: &[u8]) -> Vec<PackChunk> {
496    let total = bytes.len();
497    if total == 0 {
498        return vec![PackChunk {
499            pack_id,
500            offset: Some(0),
501            data: Some(Vec::new()),
502            last: Some(true),
503            ..Default::default()
504        }];
505    }
506    let mut chunks = Vec::new();
507    let mut iter_pos = 0usize;
508    let mut offset = 0u64;
509    while iter_pos < total {
510        let end = core::cmp::min(iter_pos + PACK_CHUNK_DATA_MAX, total);
511        chunks.push(PackChunk {
512            pack_id: pack_id.clone(),
513            offset: Some(offset),
514            data: Some(bytes[iter_pos..end].to_vec()),
515            last: Some(end == total),
516            ..Default::default()
517        });
518        offset += (end - iter_pos) as u64;
519        iter_pos = end;
520    }
521    chunks
522}
523
524/// Build the `ListRefsResponse` ref-entry list from a transport's refs.
525fn list_refs_entries(refs: Vec<mkit_core::refs::Ref>) -> Vec<RefEntry> {
526    refs.into_iter()
527        .map(|r| RefEntry {
528            name: Some(r.name),
529            object_id: r.hash.map(|h| h.to_vec()),
530            ..Default::default()
531        })
532        .collect()
533}
534
535/// Outcome of a non-streaming verb: either a single response body or a
536/// protocol error to surface to the client. The `Ok` body may itself be
537/// an `Error` frame when the reply needs dynamic payload the static
538/// `VerbError` shape cannot carry (the §4.2.1 CAS-conflict reply built
539/// by [`cas_conflict_body`]); dispatchers send it like any response.
540type SimpleVerb = Result<ssh_frame::Body, VerbError>;
541
542/// Handle every non-streaming verb (`PackExists`, `ReadRef`, `UpdateRef`,
543/// `ListRefs`) against `tx`, returning the response body or a protocol
544/// error. The streaming verbs (`DownloadPack`, `UploadPack`) are handled
545/// by the transport-specific dispatchers because they require multiple
546/// frames, but they reuse [`pack_key_from_id`], [`download_chunks`], and
547/// [`UploadDrain`].
548fn handle_simple_verb(tx: &FileTransport, body: &ssh_frame::Body) -> Option<SimpleVerb> {
549    Some(match body {
550        ssh_frame::Body::PackExists(req) => match pack_key_from_id(req.pack_id.as_ref()) {
551            Ok(key) => {
552                let exists = tx.pack_exists(&key).unwrap_or(false);
553                Ok(ssh_frame::Body::PackExistsResponse(Box::new(
554                    PackExistsResponse {
555                        exists: Some(exists),
556                        ..Default::default()
557                    },
558                )))
559            }
560            Err(e) => Err(e),
561        },
562        ssh_frame::Body::ReadRef(req) => {
563            let name = req.name.clone().unwrap_or_default();
564            match tx.read_ref(&name) {
565                Ok(found) => Ok(ssh_frame::Body::ReadRefResponse(Box::new(
566                    ReadRefResponse {
567                        object_id: Some(found.map(|h| h.to_vec()).unwrap_or_default()),
568                        ..Default::default()
569                    },
570                ))),
571                Err(_) => Err((ErrorCode::Internal, "read ref failed")),
572            }
573        }
574        ssh_frame::Body::UpdateRef(req) => {
575            let (name, new_h, condition) = match decode_update_ref(req) {
576                Ok(v) => v,
577                Err(e) => return Some(Err(e)),
578            };
579            match tx.update_ref(&name, condition, &new_h) {
580                Ok(()) => Ok(ssh_frame::Body::UpdateRefResponse(Box::default())),
581                // SPEC-TRANSPORT §4.2.1: a CAS mismatch is answered with
582                // `Error{INVALID_REQUEST}` carrying the CURRENT ref value
583                // in `details`, which clients classify as `RefConflict`.
584                // Built here (as an Ok response body) rather than through
585                // the static `VerbError` path because it carries dynamic
586                // `details` bytes.
587                Err(TransportError::RefConflict) => Ok(cas_conflict_body(tx, &name)),
588                Err(_) => Err((ErrorCode::InvalidRequest, "update ref failed")),
589            }
590        }
591        ssh_frame::Body::ListRefs(req) => {
592            let prefix = req.prefix.clone().unwrap_or_default();
593            match tx.list_refs(&prefix) {
594                Ok(refs) => Ok(ssh_frame::Body::ListRefsResponse(Box::new(
595                    ListRefsResponse {
596                        refs: list_refs_entries(refs),
597                        ..Default::default()
598                    },
599                ))),
600                Err(_) => Err((ErrorCode::Internal, "list refs failed")),
601            }
602        }
603        // Streaming and protocol-control frames are handled by the caller.
604        _ => return None,
605    })
606}
607
608/// Build the SPEC-TRANSPORT §4.2.1 CAS-mismatch reply for `update_ref`:
609/// `Error { code = ERROR_CODE_INVALID_REQUEST }` with the current ref
610/// value (the raw 32-byte digest) in `Error.details`.
611///
612/// `FileTransport::update_ref` reports a conflict without the winning
613/// value, so we read the ref back here. The read happens outside the
614/// CAS critical section, which is fine: any value it observes was the
615/// ref's current value at some point after the failed CAS, exactly
616/// what the loser needs to recover.
617///
618/// Ref-absent case: when the read finds no ref (a `MATCH` expectation
619/// against a ref that never existed, or the ref vanished between the
620/// failed CAS and this read) there is no current value to surface.
621/// `details` stays empty — mirroring `ReadRefResponse`'s
622/// empty-means-absent encoding — and strict clients (which require
623/// non-empty `details` to classify `RefConflict`) surface the
624/// descriptive message as a remote error instead of fabricating a
625/// current id.
626fn cas_conflict_body(tx: &FileTransport, name: &str) -> ssh_frame::Body {
627    let current = tx.read_ref(name).ok().flatten();
628    let (details, message) = match current {
629        Some(h) => (
630            h.to_vec(),
631            "ref update conflict: expectation does not match current ref value",
632        ),
633        None => (
634            Vec::new(),
635            "ref update conflict: expectation not met and ref is currently absent",
636        ),
637    };
638    ssh_frame::Body::Error(Box::new(
639        mkit_rpc::mkit::rpc::v1::Error::default()
640            .with_code(ErrorCode::InvalidRequest)
641            .with_message(message)
642            .with_details(details),
643    ))
644}
645
646struct UploadDrain {
647    key: PackKey,
648    expected_total: u64,
649    next_offset: u64,
650    chunks: u32,
651    bytes: Vec<u8>,
652}
653
654#[derive(Debug, Clone, Copy)]
655struct UploadDrainError(&'static str);
656
657impl UploadDrainError {
658    fn message(self) -> &'static str {
659        self.0
660    }
661}
662
663impl UploadDrain {
664    fn new(header: &UploadPack) -> Result<Self, UploadDrainError> {
665        let key = pack_key_from_upload(header.pack_id.as_deref())?;
666        let expected_total = header
667            .total_bytes
668            .ok_or(UploadDrainError("UploadPack.total_bytes is required"))?;
669        if expected_total > MAX_BYTES_PER_CONN {
670            return Err(UploadDrainError(
671                "UploadPack.total_bytes exceeds server cap",
672            ));
673        }
674        Ok(Self {
675            key,
676            expected_total,
677            next_offset: 0,
678            chunks: 0,
679            bytes: Vec::new(),
680        })
681    }
682
683    fn push_chunk(&mut self, chunk: &PackChunk) -> Result<bool, UploadDrainError> {
684        self.chunks = self.chunks.saturating_add(1);
685        if self.chunks > MAX_FRAMES_PER_CONN {
686            return Err(UploadDrainError(
687                "too many PackChunk frames before last=true",
688            ));
689        }
690
691        let chunk_key = pack_key_from_upload(chunk.pack_id.as_deref())?;
692        if chunk_key.as_bytes() != self.key.as_bytes() {
693            return Err(UploadDrainError(
694                "PackChunk.pack_id does not match UploadPack",
695            ));
696        }
697
698        let offset = chunk
699            .offset
700            .ok_or(UploadDrainError("PackChunk.offset is required"))?;
701        if offset != self.next_offset {
702            return Err(UploadDrainError(
703                "PackChunk.offset is not the expected next offset",
704            ));
705        }
706
707        let data = chunk.data.as_deref().unwrap_or(&[]);
708        let data_len = u64::try_from(data.len())
709            .map_err(|_| UploadDrainError("PackChunk.data length overflows u64"))?;
710        let new_total = self
711            .next_offset
712            .checked_add(data_len)
713            .ok_or(UploadDrainError("PackChunk byte count overflow"))?;
714        if new_total > self.expected_total {
715            return Err(UploadDrainError(
716                "PackChunk data exceeds declared total_bytes",
717            ));
718        }
719
720        self.bytes.extend_from_slice(data);
721        self.next_offset = new_total;
722
723        if !chunk.last.unwrap_or(false) {
724            return Ok(false);
725        }
726        if self.next_offset != self.expected_total {
727            return Err(UploadDrainError(
728                "PackChunk stream ended before declared total_bytes",
729            ));
730        }
731        if hash(&self.bytes) != *self.key.as_bytes() {
732            return Err(UploadDrainError(
733                "uploaded pack bytes do not match UploadPack.pack_id",
734            ));
735        }
736        Ok(true)
737    }
738
739    fn into_parts(self) -> (Vec<u8>, PackKey) {
740        (self.bytes, self.key)
741    }
742}
743
744// Bypasses `send` because `ssh_error_frame` already returns a full
745// `SshFrame`; passing it through `send` would just wrap-and-unwrap.
746fn emit_error(w: &mut impl Write, code: ErrorCode, message: &str) -> std::io::Result<()> {
747    write_frame(w, &mkit_rpc::ssh_error_frame(code, message))
748        .map_err(|_| std::io::Error::other("frame write"))
749}
750
751fn pack_key_from_upload(bytes: Option<&[u8]>) -> Result<PackKey, UploadDrainError> {
752    let b = bytes.ok_or(UploadDrainError("pack_id missing"))?;
753    if b.len() != 32 {
754        return Err(UploadDrainError("pack_id must be 32 bytes"));
755    }
756    let mut h = [0u8; 32];
757    h.copy_from_slice(b);
758    Ok(PackKey(h))
759}
760
761/// Rough byte cost of a frame for the per-connection budget. Sums the
762/// largest size-bearing fields without re-encoding.
763fn frame_byte_estimate(f: &SshFrame) -> u64 {
764    use ssh_frame::Body;
765    match &f.body {
766        Some(Body::PackChunk(c)) => c.data.as_ref().map_or(0, Vec::len) as u64,
767        Some(Body::UploadPack(h)) => h.total_bytes.unwrap_or(0),
768        Some(Body::DownloadPackHeader(h)) => h.total_bytes.unwrap_or(0),
769        _ => 64, // small control frames; charge a baseline.
770    }
771}
772
773#[cfg(test)]
774mod tests;