1use 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: String,
37 #[arg(long = "listen-enc", value_name = "ADDR")]
51 listen_enc: Option<String>,
52
53 #[arg(long = "enc-authorized-peers", value_name = "PATH")]
61 enc_authorized_peers: Option<String>,
62
63 #[arg(long = "enc-server-key", value_name = "PATH")]
68 enc_server_key: Option<String>,
69
70 #[arg(long = "unsafe-allow-any-enc-peer", default_value_t = false)]
74 unsafe_allow_any_enc_peer: bool,
75
76 #[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 #[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 #[arg(long = "http", value_name = "ADDR")]
113 http: Option<String>,
114
115 #[arg(long = "http-token", value_name = "TOKEN")]
121 http_token: Option<String>,
122
123 #[arg(long = "unsafe-allow-any-http-peer", default_value_t = false)]
128 unsafe_allow_any_http_peer: bool,
129}
130
131pub(crate) const MAX_FRAMES_PER_CONN: u32 = 10_000;
137pub(crate) const MAX_BYTES_PER_CONN: u64 = 1024 * 1024 * 1024; const 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
195use enc::run_listen_enc;
197#[cfg(all(test, feature = "enc-transport"))]
200use enc::{load_authorized_peers, serve_enc_session};
201#[cfg(feature = "sparse-checkout")]
205#[doc(hidden)]
206pub use sparse::{SparseServeError, build_sparse_response_from_tree};
207
208pub(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
226pub(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 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 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 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
420type VerbError = (ErrorCode, &'static str);
435
436fn 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
447fn 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#[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
524fn 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
535type SimpleVerb = Result<ssh_frame::Body, VerbError>;
541
542fn 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 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 _ => return None,
605 })
606}
607
608fn 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
744fn 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
761fn 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, }
771}
772
773#[cfg(test)]
774mod tests;