Skip to main content

mkit_rpc/
helpers.rs

1//! Small frame-construction helpers shared by signer subprocesses and
2//! transports (SSH + encrypted-stream). These are pure builders — no
3//! I/O — so callers can still choose how / whether to write them. Use
4//! [`super::write_frame`] to put them on the wire.
5//!
6//! Two groups live here:
7//!
8//! * Error-frame builders (`signer_error_frame`, `ssh_error_frame`):
9//!   used by signer subprocesses and the SSH server to construct
10//!   per-request `Error` responses.
11//!
12//! * Transport-side frame helpers (`cond_to_wire`,
13//!   `rpc_error_to_transport`, `map_update_ref_error`,
14//!   `unexpected_frame`, `body_name`,
15//!   `ref_entry_to_ref`, plus the `MAX_REF_NAME` and `CHUNK_DATA_MAX`
16//!   limits): shared between `mkit-transport-ssh` and
17//!   `mkit-transport-enc`. Both transports speak the same
18//!   `SshFrame` wire, so the response-parsing helpers are
19//!   transport-independent; the only thing that differs is the
20//!   advisory string baked into a `RemoteError`, which is parameterised
21//!   via a `&str` label (e.g. `"ssh"` / `"enc"`).
22
23use crate::mkit::common::v1::{RefEntry, RefExpectation};
24use crate::mkit::rpc::v1::Error as RpcError;
25use crate::mkit::rpc::v1::ErrorCode;
26use crate::mkit::rpc::v1::signer::{SignerFrame, signer_frame};
27use crate::mkit::rpc::v1::ssh::{SshFrame, ssh_frame};
28
29use mkit_core::hash::Hash;
30use mkit_core::protocol::{TransportError, TransportResult};
31use mkit_core::refs::{Ref, RefWriteCondition, validate_ref_name_grammar};
32
33// ---------------------------------------------------------------------------
34// Error-frame builders (used by signer subprocesses + SSH server)
35// ---------------------------------------------------------------------------
36
37/// Build a [`SignerFrame`] carrying a per-request `Error`. All three
38/// reference signers (file / tpm / ctap) share this shape; factored
39/// here so it stays in lockstep with the proto schema.
40#[inline]
41pub fn signer_error_frame(code: ErrorCode, message: impl Into<String>) -> SignerFrame {
42    SignerFrame {
43        body: Some(signer_frame::Body::Error(Box::new(
44            RpcError::default()
45                .with_code(code)
46                .with_message(message)
47                .with_details(Vec::new()),
48        ))),
49        ..Default::default()
50    }
51}
52
53/// Build an [`SshFrame`] carrying a server-side `Error`. Mirror of
54/// [`signer_error_frame`] for the SSH wire.
55#[inline]
56pub fn ssh_error_frame(code: ErrorCode, message: impl Into<String>) -> SshFrame {
57    SshFrame {
58        body: Some(ssh_frame::Body::Error(Box::new(
59            RpcError::default()
60                .with_code(code)
61                .with_message(message)
62                .with_details(Vec::new()),
63        ))),
64        ..Default::default()
65    }
66}
67
68// ---------------------------------------------------------------------------
69// Transport-side limits + frame helpers (shared by transport-ssh / -enc)
70// ---------------------------------------------------------------------------
71
72/// Maximum ref / prefix name length, in bytes, accepted by client-side
73/// validation before sending a frame: SPEC-REFS §3's ref-name bound,
74/// [`mkit_core::refs::MAX_REF_NAME_BYTES`], which servers enforce, so the
75/// client fails fast without a round-trip.
76pub const MAX_REF_NAME: usize = mkit_core::refs::MAX_REF_NAME_BYTES;
77
78/// Per-frame pack-data segment cap. Pack uploads chunk the body into
79/// frames this size so the framing layer's 1 MiB length cap
80/// accommodates protobuf overhead on top of the data segment.
81pub const CHUNK_DATA_MAX: usize = 800 * 1024;
82
83/// Encode a [`RefWriteCondition`] into the two on-wire fields the
84/// `UpdateRef` message carries: the (often empty) `expected_id` bytes
85/// and the `RefExpectation` enum. Production and test paths share this
86/// so the test cannot drift from the production encoding. See
87/// SPEC-TRANSPORT §4.2.1.
88#[must_use]
89pub fn cond_to_wire(c: RefWriteCondition) -> (Vec<u8>, RefExpectation) {
90    match c {
91        RefWriteCondition::Any => (Vec::new(), RefExpectation::Any),
92        RefWriteCondition::Missing => (Vec::new(), RefExpectation::Missing),
93        RefWriteCondition::Match(h) => (h.to_vec(), RefExpectation::Match),
94    }
95}
96
97/// Map a server `Error` reply to an `update_ref` request into a
98/// [`TransportError`]. Shared by `mkit-transport-ssh` and
99/// `mkit-transport-enc` so the two clients classify CAS conflicts
100/// identically and cannot drift.
101///
102/// Per SPEC-TRANSPORT §4.2.1, the server signals a compare-and-swap
103/// mismatch as `ERROR_CODE_INVALID_REQUEST` carrying the *current* ref
104/// id in `details`. We treat that as [`TransportError::RefConflict`].
105///
106/// The bare `ERROR_CODE_INVALID_REQUEST` code alone is ambiguous: the
107/// server reuses it for genuine bad requests (malformed ref, backend
108/// failure) as well as CAS mismatches. To avoid masking a real error as
109/// a conflict we only treat it as `RefConflict` when:
110///   - the write carried a CAS precondition (`condition != Any`), and
111///   - the error carries non-empty `details` (the documented current-id
112///     payload that disambiguates a true CAS mismatch).
113///
114/// When `details` is absent we fall back to [`rpc_error_to_transport`]
115/// so a genuine invalid-request surfaces its real message instead of a
116/// misleading `RefConflict`. That fallback also covers the rare
117/// conflict-then-ref-absent case (a `MATCH` expectation against a ref
118/// that does not exist): the server has no current value to put in
119/// `details`, so the failure surfaces as a `RemoteError` carrying the
120/// server's descriptive message.
121#[must_use]
122pub fn map_update_ref_error(
123    e: RpcError,
124    condition: RefWriteCondition,
125    transport: &str,
126) -> TransportError {
127    let is_invalid_request = e.code.is_some_and(|c| c == ErrorCode::InvalidRequest);
128    let has_cas_details = e.details.as_deref().is_some_and(|d| !d.is_empty());
129    if is_invalid_request && !matches!(condition, RefWriteCondition::Any) && has_cas_details {
130        TransportError::RefConflict
131    } else {
132        rpc_error_to_transport(e, transport)
133    }
134}
135
136/// Map a wire-level [`RpcError`] into a [`TransportError`]. `transport`
137/// is a short tag (`"ssh"` / `"enc"`) baked into the catch-all
138/// `RemoteError` message so logs say which transport surfaced the
139/// failure.
140///
141/// Per SPEC-RPC §3.3 / §4, every `Error` frame MUST carry a known
142/// non-zero `ErrorCode` — `code = 0` (`ERROR_CODE_UNSPECIFIED`) or an
143/// absent `code` field is itself a protocol violation, not a
144/// well-formed-but-uninteresting error. Receivers MUST treat it as
145/// such rather than collapsing it into the generic `RemoteError` a
146/// legitimate-but-unmapped code (e.g. `ERROR_CODE_INTERNAL`) would
147/// produce, so a server that stops setting `code` is distinguishable
148/// from one returning ordinary application errors.
149#[must_use]
150pub fn rpc_error_to_transport(e: RpcError, transport: &str) -> TransportError {
151    if e.code.is_some_and(|c| c == ErrorCode::KeyNotFound) {
152        return TransportError::PackNotFound;
153    }
154    if e.code.is_some_and(|c| c == ErrorCode::UserDeclined) {
155        return TransportError::AccessDenied;
156    }
157    if e.code.is_none_or(|c| c == ErrorCode::Unspecified) {
158        return TransportError::ProtocolError;
159    }
160    let msg = e.message.unwrap_or_default();
161    if msg.is_empty() {
162        TransportError::RemoteError(format!("{transport} server returned an unspecified error"))
163    } else {
164        TransportError::RemoteError(msg)
165    }
166}
167
168/// Build a [`TransportError::RemoteError`] reporting an unexpected
169/// frame variant. `want` is the human-readable body name the caller
170/// expected, `got` is whatever it actually received.
171#[must_use]
172pub fn unexpected_frame(
173    transport: &str,
174    want: &str,
175    got: Option<ssh_frame::Body>,
176) -> TransportError {
177    TransportError::RemoteError(format!(
178        "{transport} server returned {} when {want} was expected",
179        body_name(&got),
180    ))
181}
182
183/// Stringify an `SshFrame` body variant. Used for diagnostic messages
184/// in [`unexpected_frame`] and from transport tests that assert on the
185/// rejected body.
186#[must_use]
187pub fn body_name(b: &Option<ssh_frame::Body>) -> &'static str {
188    use ssh_frame::Body;
189    match b {
190        Some(Body::Hello(_)) => "hello",
191        Some(Body::HelloResponse(_)) => "hello_response",
192        Some(Body::Error(_)) => "error",
193        Some(Body::Close(_)) => "close",
194        Some(Body::ListRefs(_)) => "list_refs",
195        Some(Body::ListRefsResponse(_)) => "list_refs_response",
196        Some(Body::ReadRef(_)) => "read_ref",
197        Some(Body::ReadRefResponse(_)) => "read_ref_response",
198        Some(Body::UpdateRef(_)) => "update_ref",
199        Some(Body::UpdateRefResponse(_)) => "update_ref_response",
200        Some(Body::PackExists(_)) => "pack_exists",
201        Some(Body::PackExistsResponse(_)) => "pack_exists_response",
202        Some(Body::UploadPack(_)) => "upload_pack",
203        Some(Body::UploadPackResponse(_)) => "upload_pack_response",
204        Some(Body::DownloadPack(_)) => "download_pack",
205        Some(Body::DownloadPackHeader(_)) => "download_pack_header",
206        Some(Body::PackChunk(_)) => "pack_chunk",
207        None => "(empty body)",
208    }
209}
210
211/// Validate + convert a wire-level [`RefEntry`] into a [`Ref`]. Fails
212/// the response if the name violates the SPEC-REFS §3 grammar or the
213/// object id isn't exactly 32 bytes. The §3 length bound is not checked
214/// here: [`list_response_refs`] skips a listed name over it, so one ref a
215/// pre-bound server still holds cannot fail a whole listing.
216///
217/// # Errors
218///
219/// * [`TransportError::InvalidRef`] — name is empty or fails the
220///   ref-name grammar.
221/// * [`TransportError::InvalidResponse`] — object id is not 32 bytes.
222pub fn ref_entry_to_ref(e: RefEntry) -> TransportResult<Ref> {
223    let name = e.name.unwrap_or_default();
224    if !validate_ref_name_grammar(&name) {
225        return Err(TransportError::InvalidRef(name));
226    }
227    let oid = e.object_id.unwrap_or_default();
228    if oid.len() != 32 {
229        return Err(TransportError::InvalidResponse);
230    }
231    let mut h: Hash = [0u8; 32];
232    h.copy_from_slice(&oid);
233    Ok(Ref {
234        name,
235        hash: Some(h),
236    })
237}
238
239/// The refs of a `ListRefsResponse` to a request for `prefix`, skipping
240/// every entry whose full name (the prefix plus `/` plus the listed name)
241/// is over [`MAX_REF_NAME`], as the file, memory, s3 and http clients skip
242/// such names: a server from before SPEC-REFS §3's bound may still hold
243/// one, and the client could never read or write it.
244///
245/// The CLI fetch path does not currently report how many names were skipped;
246/// `Transport::list_refs` returns only the refs.
247///
248/// # Errors
249/// [`ref_entry_to_ref`]'s, for any entry that is not skipped.
250pub fn list_response_refs(prefix: &str, entries: Vec<RefEntry>) -> TransportResult<Vec<Ref>> {
251    let trimmed = prefix.trim_end_matches('/');
252    let prefix_len = if trimmed.is_empty() {
253        0
254    } else {
255        trimmed.len() + 1
256    };
257    entries
258        .into_iter()
259        .filter(|e| prefix_len + e.name.as_ref().map_or(0, String::len) <= MAX_REF_NAME)
260        .map(ref_entry_to_ref)
261        .collect()
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use buffa::Message;
268
269    /// One over-long entry from a pre-bound server is skipped, not a
270    /// failed listing; the bound counts the prefix.
271    #[test]
272    fn list_response_refs_skips_names_over_the_bound() {
273        let entry = |name: &str| {
274            RefEntry::default()
275                .with_name(name)
276                .with_object_id(vec![7u8; 32])
277        };
278        let long = vec!["x".repeat(100); 6].join("/");
279        assert_eq!(long.len(), 605);
280        let fits = "y".repeat(MAX_REF_NAME - "refs/heads/".len());
281        let over = format!("{fits}y");
282        let listed = list_response_refs(
283            "refs/heads/",
284            vec![entry("main"), entry(&long), entry(&fits), entry(&over)],
285        )
286        .unwrap();
287        let names: Vec<_> = listed.iter().map(|r| r.name.as_str()).collect();
288        assert_eq!(names, ["main", fits.as_str()]);
289        // Without a prefix, the listed name is the full name.
290        let full = format!("refs/heads/{long}");
291        let listed = list_response_refs("", vec![entry(&full), entry("refs/heads/a")]).unwrap();
292        assert_eq!(listed.len(), 1);
293        // A name the grammar rejects still fails the response.
294        assert!(list_response_refs("", vec![entry("bad name")]).is_err());
295    }
296
297    #[test]
298    fn signer_error_frame_round_trips() {
299        let frame = signer_error_frame(ErrorCode::InvalidRequest, "bad");
300        let bytes = frame.encode_to_vec();
301        let decoded = SignerFrame::decode(&mut &bytes[..]).expect("decode");
302        let Some(signer_frame::Body::Error(e)) = decoded.body else {
303            panic!("expected Error body");
304        };
305        assert_eq!(e.code, Some(ErrorCode::InvalidRequest.into()));
306        assert_eq!(e.message.as_deref(), Some("bad"));
307        assert_eq!(e.details.as_deref(), Some(&[][..]));
308    }
309
310    #[test]
311    fn ssh_error_frame_round_trips() {
312        let frame = ssh_error_frame(ErrorCode::KeyNotFound, "missing");
313        let bytes = frame.encode_to_vec();
314        let decoded = SshFrame::decode(&mut &bytes[..]).expect("decode");
315        let Some(ssh_frame::Body::Error(e)) = decoded.body else {
316            panic!("expected Error body");
317        };
318        assert_eq!(e.code, Some(ErrorCode::KeyNotFound.into()));
319        assert_eq!(e.message.as_deref(), Some("missing"));
320    }
321
322    #[test]
323    fn cond_to_wire_encodes_any() {
324        let (id, exp) = cond_to_wire(RefWriteCondition::Any);
325        assert!(id.is_empty());
326        assert_eq!(exp, RefExpectation::Any);
327    }
328
329    #[test]
330    fn cond_to_wire_encodes_missing() {
331        let (id, exp) = cond_to_wire(RefWriteCondition::Missing);
332        assert!(id.is_empty());
333        assert_eq!(exp, RefExpectation::Missing);
334    }
335
336    #[test]
337    fn cond_to_wire_encodes_match() {
338        let h: Hash = [7u8; 32];
339        let (id, exp) = cond_to_wire(RefWriteCondition::Match(h));
340        assert_eq!(id, h.to_vec());
341        assert_eq!(exp, RefExpectation::Match);
342    }
343
344    #[test]
345    fn rpc_error_to_transport_maps_known_codes() {
346        let not_found = RpcError::default()
347            .with_code(ErrorCode::KeyNotFound)
348            .with_message("missing pack");
349        assert!(matches!(
350            rpc_error_to_transport(not_found, "ssh"),
351            TransportError::PackNotFound
352        ));
353
354        let declined = RpcError::default().with_code(ErrorCode::UserDeclined);
355        assert!(matches!(
356            rpc_error_to_transport(declined, "ssh"),
357            TransportError::AccessDenied
358        ));
359    }
360
361    #[test]
362    fn rpc_error_to_transport_falls_back_with_label() {
363        let empty = RpcError::default().with_code(ErrorCode::InvalidRequest);
364        match rpc_error_to_transport(empty, "enc") {
365            TransportError::RemoteError(msg) => assert!(msg.contains("enc server")),
366            other => panic!("unexpected variant: {other:?}"),
367        }
368    }
369
370    #[test]
371    fn rpc_error_to_transport_treats_zero_code_as_protocol_error() {
372        // SPEC-RPC §3.3/§4: ERROR_CODE_UNSPECIFIED (wire 0) is itself a
373        // protocol violation, not a well-formed-but-generic error — it
374        // must be distinguishable from a legitimate unmapped code like
375        // ERROR_CODE_INTERNAL, even when a message is present.
376        let zero_with_message = RpcError::default()
377            .with_code(ErrorCode::Unspecified)
378            .with_message("something went wrong");
379        assert!(matches!(
380            rpc_error_to_transport(zero_with_message, "ssh"),
381            TransportError::ProtocolError
382        ));
383    }
384
385    #[test]
386    fn rpc_error_to_transport_treats_absent_code_as_protocol_error() {
387        // A completely absent `code` field (never set by a conforming
388        // producer) must classify the same as the explicit zero value —
389        // both mean "no known non-zero ErrorCode was carried".
390        let absent = RpcError::default().with_message("no code at all");
391        assert_eq!(absent.code, None);
392        assert!(matches!(
393            rpc_error_to_transport(absent, "enc"),
394            TransportError::ProtocolError
395        ));
396    }
397
398    #[test]
399    fn rpc_error_to_transport_distinguishes_unspecified_from_mapped_internal_error() {
400        // A legitimate, mapped-but-uninteresting code (INTERNAL = 99)
401        // must still fall through to the generic RemoteError path — only
402        // the zero/absent case gets the stricter ProtocolError treatment.
403        let internal = RpcError::default()
404            .with_code(ErrorCode::Internal)
405            .with_message("boom");
406        match rpc_error_to_transport(internal, "ssh") {
407            TransportError::RemoteError(msg) => assert_eq!(msg, "boom"),
408            other => panic!("unexpected variant: {other:?}"),
409        }
410    }
411
412    #[test]
413    fn unexpected_frame_includes_labels() {
414        let err = unexpected_frame("ssh", "UpdateRefResponse", None);
415        match err {
416            TransportError::RemoteError(msg) => {
417                assert!(msg.contains("ssh server"));
418                assert!(msg.contains("UpdateRefResponse"));
419                assert!(msg.contains("(empty body)"));
420            }
421            other => panic!("unexpected variant: {other:?}"),
422        }
423    }
424
425    #[test]
426    fn ref_entry_to_ref_rejects_bad_oid_length() {
427        let bad = RefEntry::default()
428            .with_name("refs/heads/main")
429            .with_object_id(vec![1, 2, 3]);
430        assert!(matches!(
431            ref_entry_to_ref(bad),
432            Err(TransportError::InvalidResponse)
433        ));
434    }
435
436    #[test]
437    fn ref_entry_to_ref_rejects_invalid_name() {
438        let bad = RefEntry::default()
439            .with_name("not a ref")
440            .with_object_id(vec![0u8; 32]);
441        assert!(matches!(
442            ref_entry_to_ref(bad),
443            Err(TransportError::InvalidRef(_))
444        ));
445    }
446
447    #[test]
448    fn ref_entry_to_ref_accepts_valid() {
449        let oid = vec![9u8; 32];
450        let ok = RefEntry::default()
451            .with_name("refs/heads/main")
452            .with_object_id(oid.clone());
453        let r = ref_entry_to_ref(ok).expect("valid");
454        assert_eq!(r.name, "refs/heads/main");
455        assert_eq!(r.hash.unwrap().to_vec(), oid);
456    }
457}