Skip to main content

ballistics_engine/bridge/
mod.rs

1//! Versioned JSON command bridge for embedded (mobile/FFI) consumers.
2//!
3//! One entry point, [`bridge_call`], accepts a JSON envelope and returns a JSON
4//! envelope. Request semantics live in the transport-free library services
5//! (starting with [`crate::solve_v1()`]); this module contains only the envelope
6//! contract, command dispatch, and panic containment. The C ABI wrapper lives in
7//! [`crate::bridge::ffi`] (feature `ffi`).
8//!
9//! ## Envelope contract (v1)
10//!
11//! Request:
12//! ```json
13//! { "api_version": 1, "command": "solve", "request": { ... } }
14//! ```
15//!
16//! Success response:
17//! ```json
18//! { "ok": true, "api_version": 1, "engine_version": "0.33.1",
19//!   "command": "solve", "result": { ... } }
20//! ```
21//!
22//! Error response (always in-band; the bridge never signals failure any other way):
23//! ```json
24//! { "ok": false, "api_version": 1, "engine_version": "0.33.1",
25//!   "error": { "code": "command_failed", "message": "...", "details": { ... } } }
26//! ```
27//!
28//! Compatibility policy: the envelope itself rejects unknown fields (a caller that
29//! misspells `command` should hear about it), while inner `request` payloads follow
30//! each command's own schema discipline (e.g. `solve` uses the solve-json v1
31//! decoder, which also rejects unknown fields with location info). New commands
32//! and new OPTIONAL response fields may appear within api_version 1; anything that
33//! would break an existing well-formed caller bumps `BRIDGE_API_VERSION`. Callers
34//! feature-detect with `meta.capabilities` instead of sniffing versions.
35
36#[cfg(feature = "ffi")]
37pub mod ffi;
38
39use serde::{Deserialize, Serialize};
40use serde_json::{json, Value};
41use std::panic::{catch_unwind, AssertUnwindSafe};
42
43/// Bridge envelope version. Bumped only for breaking envelope changes.
44pub const BRIDGE_API_VERSION: u32 = 1;
45
46/// Hard cap on request size, matching the solve-json transport.
47pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
48
49const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
50
51/// Commands available in this build, in dispatch order.
52/// `meta.capabilities` reports exactly this list so apps can feature-detect.
53fn command_names() -> Vec<&'static str> {
54    let mut names = vec![
55        "meta.capabilities",
56        "meta.version",
57        "solve",
58        "card.come_ups",
59        "card.range_table",
60        "card.wind",
61    ];
62    // Listed ONLY when compiled in (mirroring compiled_features) so apps feature-detect
63    // the command list instead of probing for unknown_command. Each conditional push sits
64    // at its dispatch position so this list stays in dispatch order, as documented above.
65    #[cfg(feature = "pdf")]
66    names.push("card.pdf");
67    names.extend(["profile.validate", "profile.normalize"]);
68    #[cfg(feature = "profile-import")]
69    names.push("profile.import_a7p");
70    // Filesystem-backed (BC5D tables are loaded from caller-supplied paths), so absent on
71    // wasm32 — the same "list only what this build can run" rule as profile.import_a7p.
72    #[cfg(not(target_arch = "wasm32"))]
73    names.push("bc5d.info");
74    names
75}
76
77fn compiled_features() -> Vec<&'static str> {
78    [
79        ("pdf", cfg!(feature = "pdf")),
80        ("profile-import", cfg!(feature = "profile-import")),
81        ("online", cfg!(feature = "online")),
82    ]
83    .iter()
84    .filter(|(_, enabled)| *enabled)
85    .map(|(name, _)| *name)
86    .collect()
87}
88
89#[derive(Debug, Deserialize)]
90#[serde(deny_unknown_fields)]
91struct BridgeRequest {
92    api_version: u32,
93    command: String,
94    #[serde(default)]
95    request: Value,
96}
97
98/// Machine-readable bridge error codes. Distinct from any command's own error
99/// vocabulary: a `command_failed` carries the command's typed error in `details`.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
101#[serde(rename_all = "snake_case")]
102pub enum BridgeErrorCode {
103    InvalidJson,
104    UnsupportedApiVersion,
105    UnknownCommand,
106    InvalidRequest,
107    ResourceLimit,
108    CommandFailed,
109    InternalError,
110}
111
112fn success(command: &str, result: Value) -> String {
113    serialize_envelope(&json!({
114        "ok": true,
115        "api_version": BRIDGE_API_VERSION,
116        "engine_version": ENGINE_VERSION,
117        "command": command,
118        "result": result,
119    }))
120}
121
122fn error(code: BridgeErrorCode, message: impl Into<String>, details: Option<Value>) -> String {
123    let mut error = json!({
124        "code": code,
125        "message": message.into(),
126    });
127    if let Some(details) = details {
128        error["details"] = details;
129    }
130    serialize_envelope(&json!({
131        "ok": false,
132        "api_version": BRIDGE_API_VERSION,
133        "engine_version": ENGINE_VERSION,
134        "error": error,
135    }))
136}
137
138/// Serialization of the envelope itself must not be able to fail the bridge:
139/// fall back to a hand-written internal_error document.
140fn serialize_envelope(value: &Value) -> String {
141    serde_json::to_string(value).unwrap_or_else(|_| {
142        format!(
143            r#"{{"ok":false,"api_version":{BRIDGE_API_VERSION},"engine_version":"{ENGINE_VERSION}","error":{{"code":"internal_error","message":"bridge response serialization failed"}}}}"#
144        )
145    })
146}
147
148/// Process one bridge exchange. Never panics; every failure mode is an in-band
149/// error envelope. This is the function the C ABI wraps.
150pub fn bridge_call(request_json: &str) -> String {
151    let guarded = catch_unwind(AssertUnwindSafe(|| dispatch(request_json)));
152    guarded.unwrap_or_else(|_| {
153        error(
154            BridgeErrorCode::InternalError,
155            "bridge command failed unexpectedly",
156            None,
157        )
158    })
159}
160
161fn dispatch(request_json: &str) -> String {
162    if request_json.len() > MAX_REQUEST_BYTES {
163        return error(
164            BridgeErrorCode::ResourceLimit,
165            format!("bridge request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
166            None,
167        );
168    }
169
170    let request: BridgeRequest = match serde_json::from_str(request_json) {
171        Ok(request) => request,
172        Err(err) => {
173            return error(
174                BridgeErrorCode::InvalidJson,
175                format!("bridge request is not a valid envelope: {err}"),
176                None,
177            )
178        }
179    };
180
181    if request.api_version != BRIDGE_API_VERSION {
182        return error(
183            BridgeErrorCode::UnsupportedApiVersion,
184            format!(
185                "unsupported api_version {}; this build speaks {BRIDGE_API_VERSION}",
186                request.api_version
187            ),
188            None,
189        );
190    }
191
192    match request.command.as_str() {
193        "meta.capabilities" => success(
194            "meta.capabilities",
195            json!({
196                "engine_version": ENGINE_VERSION,
197                "bridge_api_version": BRIDGE_API_VERSION,
198                "commands": command_names(),
199                "features": compiled_features(),
200                "solve_schema_version": crate::solve_json::SOLVE_JSON_SCHEMA_VERSION_V1,
201            }),
202        ),
203        "meta.version" => success(
204            "meta.version",
205            json!({ "engine_version": ENGINE_VERSION }),
206        ),
207        "solve" => run_solve(&request.request),
208        "card.come_ups" => run_card(&request.request, "card.come_ups", crate::card_service::come_ups_v1),
209        "card.range_table" => {
210            run_card(&request.request, "card.range_table", crate::card_service::range_table_v1)
211        }
212        "card.wind" => run_card(&request.request, "card.wind", crate::card_service::wind_card_v1),
213        #[cfg(feature = "pdf")]
214        "card.pdf" => run_card_pdf(&request.request),
215        "profile.validate" => run_profile_validate(&request.request),
216        "profile.normalize" => run_profile_normalize(&request.request),
217        #[cfg(feature = "profile-import")]
218        "profile.import_a7p" => run_profile_import_a7p(&request.request),
219        #[cfg(not(target_arch = "wasm32"))]
220        "bc5d.info" => run_bc5d_info(&request.request),
221        other => error(
222            BridgeErrorCode::UnknownCommand,
223            format!(
224                "unknown command '{other}'; this build supports: {}",
225                command_names().join(", ")
226            ),
227            None,
228        ),
229    }
230}
231
232/// `solve` delegates verbatim to the solve-json v1 service. The inner request is
233/// re-serialized and run through [`crate::solve_json::decode_solve_request_v1`] so
234/// callers get the exact same schema validation (unknown-field rejection, explicit
235/// SI units, typed error locations) as the CLI `solve-json` transport.
236fn run_solve(inner: &Value) -> String {
237    if inner.is_null() {
238        return error(
239            BridgeErrorCode::InvalidRequest,
240            "'solve' requires a request payload (solve-json v1 document)",
241            None,
242        );
243    }
244    let inner_text = match serde_json::to_string(inner) {
245        Ok(text) => text,
246        Err(err) => {
247            return error(
248                BridgeErrorCode::InternalError,
249                format!("failed to re-serialize solve request: {err}"),
250                None,
251            )
252        }
253    };
254
255    let request = match crate::solve_json::decode_solve_request_v1(&inner_text) {
256        Ok(request) => request,
257        Err(envelope) => return command_error("solve request rejected", &envelope),
258    };
259
260    match crate::solve_v1(request) {
261        Ok(successful) => match serde_json::to_value(&successful) {
262            Ok(result) => success("solve", result),
263            Err(err) => error(
264                BridgeErrorCode::InternalError,
265                format!("failed to serialize solve result: {err}"),
266                None,
267            ),
268        },
269        Err(envelope) => command_error("solve failed", &envelope),
270    }
271}
272
273/// Shared runner for the three card commands: decode the typed request (rejecting
274/// unknown fields), run the transport-free service, serialize the typed response.
275fn run_card(
276    inner: &Value,
277    command: &'static str,
278    service: fn(
279        &crate::card_service::CardRequestV1,
280    ) -> Result<crate::card_service::CardResponseV1, crate::card_service::CardServiceError>,
281) -> String {
282    if inner.is_null() {
283        return error(
284            BridgeErrorCode::InvalidRequest,
285            format!("'{command}' requires a request payload (card v1 document)"),
286            None,
287        );
288    }
289    let request: crate::card_service::CardRequestV1 =
290        match serde_json::from_value(inner.clone()) {
291            Ok(request) => request,
292            Err(err) => {
293                return error(
294                    BridgeErrorCode::InvalidRequest,
295                    format!("{command} request rejected: {err}"),
296                    None,
297                )
298            }
299        };
300    match service(&request) {
301        Ok(response) => match serde_json::to_value(&response) {
302            Ok(result) => success(command, result),
303            Err(err) => error(
304                BridgeErrorCode::InternalError,
305                format!("failed to serialize {command} result: {err}"),
306                None,
307            ),
308        },
309        Err(err) => error(
310            BridgeErrorCode::CommandFailed,
311            format!("{command} failed: {err}"),
312            None,
313        ),
314    }
315}
316
317/// Hard cap on the PDF `card.pdf` will hand back, measured on the RAW document (the
318/// base64 text in the response is ~4/3 of it, so this bounds a ~5.6 MiB response body).
319///
320/// Every dope card carries a ~815 KiB floor: the two Liberation Sans faces
321/// `pdf_dope_card` embeds. Rows are cheap on top of that (~0.5 KiB each — a 300-row,
322/// 4-page card is ~950 KiB).
323///
324/// This is the BACKSTOP, not the first line: the row set is refused on its own row and page
325/// count before any document exists (`card_service::MAX_PDF_ROWS` / `MAX_PDF_PAGES`), because
326/// measuring bytes means having already built and paginated them. What survives that check
327/// and still lands here is a card made huge by its LABELS — the `pdf` block's strings are
328/// drawn verbatim on every page — and for those a typed refusal the caller can act on beats
329/// pushing a multi-megabyte base64 string through an embedded FFI hop.
330#[cfg(feature = "pdf")]
331pub const MAX_PDF_BYTES: usize = 4 * 1024 * 1024;
332
333/// The over-cap envelope for a generated PDF, or `None` when it fits. Split out so the
334/// boundary itself is unit-testable at exactly [`MAX_PDF_BYTES`] and one byte past it.
335///
336/// The message states what is true of the document — its size, and how many rows and pages
337/// it holds. It deliberately does NOT advise "coarsen the step" or "shorten the range
338/// domain": for a saved card those are immutable (there is no editor for a snapshot's
339/// domain), so naming them told the one user who ever sees this message to do something
340/// impossible.
341#[cfg(feature = "pdf")]
342fn pdf_over_cap_error(byte_length: usize, row_count: usize, page_count: usize) -> Option<String> {
343    (byte_length > MAX_PDF_BYTES).then(|| {
344        error(
345            BridgeErrorCode::ResourceLimit,
346            format!(
347                "generated dope card is {byte_length} bytes; the limit is {MAX_PDF_BYTES} \
348                 ({row_count} rows, {page_count} pages)"
349            ),
350            None,
351        )
352    })
353}
354
355/// The one `card.pdf`-only key on the request: the rows to print, instead of solving. Not a
356/// field on [`crate::card_service::CardRequestV1`], because it is not part of a saved card —
357/// it is the card's stored RESPONSE, attached at export time — and a stored request must stay
358/// replayable against `card.range_table` unchanged. Removed from the payload before the card
359/// request is decoded, so `deny_unknown_fields` still governs everything else (a
360/// `stored_cards` typo is an honest `invalid_request`).
361#[cfg(feature = "pdf")]
362const STORED_CARD_KEY: &str = "stored_card";
363
364/// `card.pdf`: the printable dope card, as base64. The request is the SAME
365/// [`crate::card_service::CardRequestV1`] the on-screen card commands take — an app stores
366/// one request per saved card and replays it here — with the optional presentation-only
367/// `pdf` block for the header/footer labels, font size, and the Lead column's target speed,
368/// plus one `card.pdf`-only key:
369///
370/// * `stored_card` (optional): `{ "card": <a stored card.range_table result, verbatim>,
371///   "engine_version": "0.34.1", "bc5d_table_version": "2.5.0" }`. Supply it and this
372///   command PRINTS THOSE ROWS: no zero solve, no trajectory, and `bc5d_table_path` is never
373///   opened, so a saved card reprints identically after an engine bump, after the correction
374///   table at that path is overwritten in place, and even after it is deleted. The footer's
375///   `BC:` is the stored card's own `bc_for_solve`, and its `Engine:`/`Table:` are the two
376///   provenance strings, so paper and screen can be reconciled afterwards.
377/// * Omit it (or send `null`, which means the same thing) and the rows are solved here, from
378///   the same `card_service::range_table_rows` call `card.range_table` makes — the
379///   pre-existing behaviour, unchanged.
380///
381/// This surface prints a range-table card and says so. A request carrying a wind card's
382/// `wind_speeds`/`wind_angles_deg`, or a `stored_card` of another kind, is REFUSED: an `ok`
383/// response whose defining field was silently ignored is worse than no response.
384///
385/// Result: `{ "pdf_base64": ..., "byte_length": <raw PDF bytes>, "page_count": ...,
386/// "row_count": ..., "kind": "range_table", "source": "solve" | "stored_rows",
387/// "unprintable_title_chars": "" }`.
388/// `byte_length` describes the DECODED document, not the base64 text; `source` lets a caller
389/// verify it got a reprint rather than a re-solve. `unprintable_title_chars` is normally
390/// empty and names the characters of `pdf.title` the card font could not draw when it is not
391/// — the card still printed, with a visible stand-in for each of them, but a caller that
392/// accepts any card name should warn rather than hand over an untitled card. A card too big
393/// to print is refused with
394/// `resource_limit` — on its row/page count first (`card_service::MAX_PDF_ROWS` /
395/// `MAX_PDF_PAGES`), and on [`MAX_PDF_BYTES`] as the backstop.
396///
397/// Other errors follow the sibling card commands exactly: a malformed payload is
398/// `invalid_request`, anything the service rejects (including an out-of-band
399/// `pdf.font_scale`) is `command_failed` with the service's own message.
400#[cfg(feature = "pdf")]
401fn run_card_pdf(inner: &Value) -> String {
402    use crate::card_service::CardServiceError;
403
404    if inner.is_null() {
405        return error(
406            BridgeErrorCode::InvalidRequest,
407            "'card.pdf' requires a request payload (card v1 document)",
408            None,
409        );
410    }
411    let mut payload = inner.clone();
412    let stored_value = payload
413        .as_object_mut()
414        .and_then(|object| object.remove(STORED_CARD_KEY))
415        .filter(|value| !value.is_null());
416    let request: crate::card_service::CardRequestV1 = match serde_json::from_value(payload) {
417        Ok(request) => request,
418        Err(err) => {
419            return error(
420                BridgeErrorCode::InvalidRequest,
421                format!("card.pdf request rejected: {err}"),
422                None,
423            )
424        }
425    };
426    let stored: Option<crate::card_service::StoredCardV1> = match stored_value {
427        Some(value) => match serde_json::from_value(value) {
428            Ok(stored) => Some(stored),
429            Err(err) => {
430                return error(
431                    BridgeErrorCode::InvalidRequest,
432                    format!("card.pdf {STORED_CARD_KEY} rejected: {err}"),
433                    None,
434                )
435            }
436        },
437        None => None,
438    };
439
440    let card = match crate::card_service::pdf_card_v1(&request, stored.as_ref()) {
441        Ok(card) => card,
442        // A card too large to print is a resource refusal, not a command failure: same code
443        // the byte cap below reports, so a caller has one condition to handle.
444        Err(err @ CardServiceError::TooLarge(_)) => {
445            return error(
446                BridgeErrorCode::ResourceLimit,
447                format!("card.pdf refused: {err}"),
448                None,
449            )
450        }
451        Err(err) => {
452            return error(
453                BridgeErrorCode::CommandFailed,
454                format!("card.pdf failed: {err}"),
455                None,
456            )
457        }
458    };
459    let byte_length = card.pdf_bytes.len();
460    if let Some(envelope) = pdf_over_cap_error(byte_length, card.row_count, card.page_count) {
461        return envelope;
462    }
463    success(
464        "card.pdf",
465        json!({
466            "pdf_base64": encode_base64(&card.pdf_bytes),
467            "byte_length": byte_length,
468            "page_count": card.page_count,
469            "row_count": card.row_count,
470            "kind": crate::card_service::PDF_CARD_KIND,
471            "source": card.source.as_str(),
472            "unprintable_title_chars": card.unprintable_title_chars,
473        }),
474    )
475}
476
477/// RFC 4648 standard-alphabet base64 encoder with padding, for `card.pdf`.
478///
479/// Hand-rolled for the same reason as `decode_base64` below (plain text, not a doc link:
480/// that function is gated on `profile-import`, which a pdf-only build need not enable): no
481/// direct base64 dependency
482/// exists in `Cargo.toml`, and adding one for twenty lines of arithmetic would ride along on
483/// all thirteen release targets.
484#[cfg(feature = "pdf")]
485fn encode_base64(bytes: &[u8]) -> String {
486    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
487    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
488    for chunk in bytes.chunks(3) {
489        let triple = (u32::from(chunk[0]) << 16)
490            | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
491            | u32::from(chunk.get(2).copied().unwrap_or(0));
492        out.push(char::from(ALPHABET[(triple >> 18) as usize & 63]));
493        out.push(char::from(ALPHABET[(triple >> 12) as usize & 63]));
494        // A 1- or 2-byte tail pads rather than encoding the zero bits it never carried.
495        out.push(if chunk.len() > 1 {
496            char::from(ALPHABET[(triple >> 6) as usize & 63])
497        } else {
498            '='
499        });
500        out.push(if chunk.len() > 2 {
501            char::from(ALPHABET[triple as usize & 63])
502        } else {
503            '='
504        });
505    }
506    out
507}
508
509/// Wrap a command's own typed error envelope losslessly in `details`.
510fn command_error<E: Serialize>(message: &str, typed: &E) -> String {
511    let details = serde_json::to_value(typed).ok();
512    error(BridgeErrorCode::CommandFailed, message, details)
513}
514
515/// Shared decode for the two profile document commands: the inner request IS a
516/// [`crate::profile::ProfileData`] JSON document (the exact schema of
517/// `~/.ballistics/profiles/*.json` — same field names, same defaults, unknown keys
518/// tolerated, exactly as the CLI loads it).
519fn decode_profile_document(
520    inner: &Value,
521    command: &'static str,
522) -> Result<crate::profile::ProfileData, String> {
523    if inner.is_null() {
524        return Err(error(
525            BridgeErrorCode::InvalidRequest,
526            format!("'{command}' requires a request payload (a ProfileData JSON document)"),
527            None,
528        ));
529    }
530    serde_json::from_value(inner.clone()).map_err(|err| {
531        error(
532            BridgeErrorCode::InvalidRequest,
533            format!("{command} request is not a ProfileData document: {err}"),
534            None,
535        )
536    })
537}
538
539/// `profile.validate`: parse a ProfileData document and run the cheap invariants the CLI
540/// applies when loading/saving a profile (units string, MBA-1358 tracking-CF band,
541/// MBA-1348 turret/optic assembly + validation including click-value parse) — see
542/// [`crate::profile::ProfileData::validation_warnings`]. No new physics checks. Result:
543/// `{ "valid": bool, "warnings": [..], "normalized": <the profile re-serialized by this
544/// engine> }` — `valid` is simply `warnings.is_empty()`.
545fn run_profile_validate(inner: &Value) -> String {
546    let profile = match decode_profile_document(inner, "profile.validate") {
547        Ok(profile) => profile,
548        Err(envelope) => return envelope,
549    };
550    let warnings = profile.validation_warnings();
551    match serde_json::to_value(&profile) {
552        Ok(normalized) => success(
553            "profile.validate",
554            json!({
555                "valid": warnings.is_empty(),
556                "warnings": warnings,
557                "normalized": normalized,
558            }),
559        ),
560        Err(err) => error(
561            BridgeErrorCode::InternalError,
562            format!("failed to serialize normalized profile: {err}"),
563            None,
564        ),
565    }
566}
567
568/// `profile.normalize`: parse a ProfileData document and hand it back re-serialized by
569/// THIS engine — the supported way for an app to round-trip a stored blob through a newer
570/// engine version (unknown keys are tolerated on input and dropped on output; defaults
571/// fill in; `skip_serializing_if` keys stay absent — the same round-trip a CLI
572/// load-then-save performs). Result: `{ "profile": <re-serialized ProfileData> }`.
573fn run_profile_normalize(inner: &Value) -> String {
574    let profile = match decode_profile_document(inner, "profile.normalize") {
575        Ok(profile) => profile,
576        Err(envelope) => return envelope,
577    };
578    match serde_json::to_value(&profile) {
579        Ok(normalized) => success("profile.normalize", json!({ "profile": normalized })),
580        Err(err) => error(
581            BridgeErrorCode::InternalError,
582            format!("failed to serialize normalized profile: {err}"),
583            None,
584        ),
585    }
586}
587
588/// `bc5d.info` request payload: the filesystem path of a downloaded BC5D table.
589#[cfg(not(target_arch = "wasm32"))]
590#[derive(Debug, Deserialize)]
591#[serde(deny_unknown_fields)]
592struct Bc5dInfoRequest {
593    path: String,
594}
595
596/// `bc5d.info`: open, parse, and CRC-verify a BC5D correction table at a caller-supplied
597/// path — the exact same load-with-verification (`bc_table_5d::path_cache::load_verified`)
598/// the `solve`/card `bc5d_table_path` fields use, so "info says valid" and "the solve will
599/// accept it" cannot drift apart. Lets an app validate a table right after downloading it.
600///
601/// Result on success: `{ "valid": true, "crc_ok": true, ... }` with the identifying
602/// metadata the header carries (format version, caliber, generator API version,
603/// generation timestamp, per-axis bin counts, total cells, weight/velocity coverage).
604/// A missing, unreadable, corrupt, or non-BC5D file is a `command_failed` envelope with
605/// a human-readable message (`invalid_request` when the payload itself is malformed).
606///
607/// `caliber` is the raw header value (an `f32`, so a .308 table reports 0.30799998) and
608/// `caliber_key` is that same value as the 3-digit BC5D key — EXACTLY the integer the
609/// solve/card caliber guard compares (`Bc5dTable::ensure_caliber_matches`). An app can
610/// therefore pre-check a downloaded table itself with
611/// `round(bullet_diameter_inches * 1000) == caliber_key` and show its own friendly
612/// message instead of provoking the `command_failed`. This command deliberately does NOT
613/// take a caliber: it describes a file, and only the surfaces that have a shot in hand
614/// enforce the match.
615#[cfg(not(target_arch = "wasm32"))]
616fn run_bc5d_info(inner: &Value) -> String {
617    if inner.is_null() {
618        return error(
619            BridgeErrorCode::InvalidRequest,
620            "'bc5d.info' requires a request payload ({\"path\": ...})",
621            None,
622        );
623    }
624    let request: Bc5dInfoRequest = match serde_json::from_value(inner.clone()) {
625        Ok(request) => request,
626        Err(err) => {
627            return error(
628                BridgeErrorCode::InvalidRequest,
629                format!("bc5d.info request rejected: {err}"),
630                None,
631            )
632        }
633    };
634
635    let table = match crate::bc_table_5d::path_cache::load_verified(std::path::Path::new(
636        &request.path,
637    )) {
638        Ok(table) => table,
639        Err(err) => {
640            return error(
641                BridgeErrorCode::CommandFailed,
642                format!("bc5d.info: not a usable BC5D table: {err}"),
643                None,
644            )
645        }
646    };
647
648    let (weight, bc, muzzle_vel, current_vel, drag_types) = table.bin_counts();
649    let (weight_lo, weight_hi) = table.weight_range();
650    let (vel_lo, vel_hi) = table.velocity_range();
651    success(
652        "bc5d.info",
653        json!({
654            // Reaching here means the magic, format version, dimensions, AND the stored
655            // CRC32 all checked out — crc_ok is not a separate weaker probe.
656            "valid": true,
657            "crc_ok": true,
658            "format_version": table.version(),
659            "caliber": table.caliber(),
660            // The integer the caliber guard actually compares (see the doc comment).
661            "caliber_key": table.caliber_key(),
662            "api_version": table.api_version(),
663            "generated_timestamp": table.timestamp(),
664            // Axis order matches the on-disk layout: [drag_type][weight][bc][mv][cv].
665            "bins": {
666                "weight": weight,
667                "bc": bc,
668                "muzzle_velocity": muzzle_vel,
669                "current_velocity": current_vel,
670                "drag_types": drag_types,
671            },
672            "total_cells": table.total_cells(),
673            "weight_range_grains": [weight_lo, weight_hi],
674            "velocity_range_fps": [vel_lo, vel_hi],
675        }),
676    )
677}
678
679/// Hard cap on the DECODED `.a7p` payload accepted by `profile.import_a7p`. Real files
680/// are a few KiB; this exists purely as a resource bound (the request envelope's own
681/// [`MAX_REQUEST_BYTES`] already caps the base64 text).
682#[cfg(feature = "profile-import")]
683pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
684
685/// `profile.import_a7p` request payload. `zero_click` mirrors the CLI's `--zero-click`
686/// (the source device's click graduation, e.g. `"0.1mil"`), enabling the same optional
687/// zero_x/zero_y click-count conversion; omitted keeps the CLI's default behavior (the
688/// counts are reported as unmapped). `strict` mirrors `--strict`: reject the file on an
689/// MD5 envelope mismatch instead of importing with a warning.
690#[cfg(feature = "profile-import")]
691#[derive(Debug, Deserialize)]
692#[serde(deny_unknown_fields)]
693struct ProfileImportA7pRequest {
694    a7p_base64: String,
695    #[serde(default)]
696    zero_click: Option<String>,
697    #[serde(default)]
698    strict: bool,
699}
700
701/// `profile.import_a7p`: run the cleanroom `.a7p` parser + the SAME
702/// [`crate::profile_import::map_a7p_to_profile`] mapping the CLI's `profile import` uses,
703/// on a base64-supplied file. Result: `{ "profile": <ProfileData>, "warnings": [..],
704/// "mapped": [[source, raw, converted, destination], ..], "unmapped": [[field, why], ..],
705/// "unknown_fields": [{context, number}, ..] }` — the full import report, nothing
706/// silently dropped (`unmapped` includes the unknown-field entries too, exactly as the
707/// CLI prints them; `unknown_fields` additionally lists the parser-level unknowns in
708/// structured form). The profile name is derived from the file (sanitized); renaming is
709/// the caller's business — there is no name override here.
710#[cfg(feature = "profile-import")]
711fn run_profile_import_a7p(inner: &Value) -> String {
712    use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
713
714    if inner.is_null() {
715        return error(
716            BridgeErrorCode::InvalidRequest,
717            "'profile.import_a7p' requires a request payload ({\"a7p_base64\": ...})",
718            None,
719        );
720    }
721    let request: ProfileImportA7pRequest = match serde_json::from_value(inner.clone()) {
722        Ok(request) => request,
723        Err(err) => {
724            return error(
725                BridgeErrorCode::InvalidRequest,
726                format!("profile.import_a7p request rejected: {err}"),
727                None,
728            )
729        }
730    };
731
732    let zero_click = match request.zero_click.as_deref() {
733        Some(raw) => match crate::adjustment::parse_click_value(raw) {
734            Ok(click) => Some(click),
735            Err(err) => {
736                return error(
737                    BridgeErrorCode::InvalidRequest,
738                    format!("profile.import_a7p zero_click: {err}"),
739                    None,
740                )
741            }
742        },
743        None => None,
744    };
745
746    let bytes = match decode_base64(&request.a7p_base64) {
747        Ok(bytes) => bytes,
748        Err(err) => {
749            return error(
750                BridgeErrorCode::InvalidRequest,
751                format!("profile.import_a7p a7p_base64: {err}"),
752                None,
753            )
754        }
755    };
756    if bytes.len() > MAX_A7P_DECODED_BYTES {
757        return error(
758            BridgeErrorCode::ResourceLimit,
759            format!(
760                "decoded .a7p payload is {} bytes; the limit is {MAX_A7P_DECODED_BYTES}",
761                bytes.len()
762            ),
763            None,
764        );
765    }
766
767    let doc = match parse_a7p(&bytes) {
768        Ok(doc) => doc,
769        Err(err) => {
770            return error(
771                BridgeErrorCode::CommandFailed,
772                format!("not a usable .a7p file: {err}"),
773                None,
774            )
775        }
776    };
777    // Same refusal (and message) as the CLI's --strict; without it the mismatch becomes
778    // a warning in the report, also exactly as the CLI behaves.
779    if request.strict {
780        if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
781            return error(
782                BridgeErrorCode::CommandFailed,
783                format!(
784                    "checksum mismatch (file says {expected}, payload hashes to {actual}) — refusing under strict"
785                ),
786                None,
787            );
788        }
789    }
790
791    let outcome = match map_a7p_to_profile(&doc, None, zero_click) {
792        Ok(outcome) => outcome,
793        Err(err) => return error(BridgeErrorCode::CommandFailed, err, None),
794    };
795    let unknown_fields: Vec<Value> = doc
796        .unknown_fields
797        .iter()
798        .map(|u| json!({ "context": u.context, "number": u.number }))
799        .collect();
800    match serde_json::to_value(&outcome.profile) {
801        Ok(profile) => success(
802            "profile.import_a7p",
803            json!({
804                "profile": profile,
805                "warnings": outcome.report.warnings,
806                "mapped": outcome.report.mapped,
807                "unmapped": outcome.report.unmapped,
808                "unknown_fields": unknown_fields,
809            }),
810        ),
811        Err(err) => error(
812            BridgeErrorCode::InternalError,
813            format!("failed to serialize imported profile: {err}"),
814            None,
815        ),
816    }
817}
818
819/// Minimal strict RFC 4648 standard-alphabet base64 decoder for `profile.import_a7p`.
820///
821/// Hand-rolled rather than a new dependency, deliberately: the crate already carries its
822/// own cleanroom MD5 (`profile_import::md5`) and statistical constants for the same
823/// thirteen-platform reasons, `Cargo.toml` has no direct base64 dependency today, and the
824/// input here is a few KiB. Strict: rejects any character outside `A-Za-z0-9+/`, `=`
825/// anywhere but as final padding, and lengths of form 4n+1.
826#[cfg(feature = "profile-import")]
827fn decode_base64(input: &str) -> Result<Vec<u8>, String> {
828    fn sextet(c: u8) -> Result<u32, String> {
829        match c {
830            b'A'..=b'Z' => Ok(u32::from(c - b'A')),
831            b'a'..=b'z' => Ok(u32::from(c - b'a') + 26),
832            b'0'..=b'9' => Ok(u32::from(c - b'0') + 52),
833            b'+' => Ok(62),
834            b'/' => Ok(63),
835            _ => Err(format!("invalid base64 character {:?}", char::from(c))),
836        }
837    }
838    let bytes = input.as_bytes();
839    let data = match bytes {
840        [rest @ .., b'=', b'='] => rest,
841        [rest @ .., b'='] => rest,
842        _ => bytes,
843    };
844    if data.contains(&b'=') {
845        return Err("'=' is only valid as trailing padding".to_string());
846    }
847    if data.len() % 4 == 1 {
848        return Err("base64 text has an impossible length (4n+1 data characters)".to_string());
849    }
850    let mut out = Vec::with_capacity(data.len() / 4 * 3 + 2);
851    let mut acc: u32 = 0;
852    let mut bits: u32 = 0;
853    for &c in data {
854        acc = (acc << 6) | sextet(c)?;
855        bits += 6;
856        if bits >= 8 {
857            bits -= 8;
858            out.push((acc >> bits) as u8);
859        }
860    }
861    Ok(out)
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867
868    fn call(value: Value) -> Value {
869        let raw = bridge_call(&value.to_string());
870        serde_json::from_str(&raw).expect("bridge output must be valid JSON")
871    }
872
873    #[test]
874    fn capabilities_reports_commands_and_versions() {
875        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
876        assert_eq!(out["ok"], true);
877        assert_eq!(out["api_version"], 1);
878        assert_eq!(out["result"]["engine_version"], ENGINE_VERSION);
879        let commands: Vec<String> =
880            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
881        assert!(commands.contains(&"solve".to_string()));
882        assert!(commands.contains(&"meta.capabilities".to_string()));
883    }
884
885    #[test]
886    fn invalid_json_is_an_envelope_not_a_panic() {
887        let out: Value = serde_json::from_str(&bridge_call("{not json")).unwrap();
888        assert_eq!(out["ok"], false);
889        assert_eq!(out["error"]["code"], "invalid_json");
890    }
891
892    #[test]
893    fn unknown_envelope_field_is_rejected() {
894        let out = call(json!({"api_version": 1, "command": "meta.version", "extra": 1}));
895        assert_eq!(out["ok"], false);
896        assert_eq!(out["error"]["code"], "invalid_json");
897    }
898
899    #[test]
900    fn unknown_command_lists_supported_ones() {
901        // Was `card.pdf` until that became a real (pdf-gated) command; use a name no build
902        // can ever dispatch so this test means the same thing in every feature set.
903        let out = call(json!({"api_version": 1, "command": "card.semaphore"}));
904        assert_eq!(out["error"]["code"], "unknown_command");
905        assert!(out["error"]["message"]
906            .as_str()
907            .unwrap()
908            .contains("meta.capabilities"));
909    }
910
911    #[test]
912    fn wrong_api_version_is_rejected() {
913        let out = call(json!({"api_version": 99, "command": "meta.version"}));
914        assert_eq!(out["error"]["code"], "unsupported_api_version");
915    }
916
917    #[test]
918    fn oversize_request_is_a_resource_limit() {
919        let big = format!(
920            r#"{{"api_version":1,"command":"meta.version","request":"{}"}}"#,
921            "x".repeat(MAX_REQUEST_BYTES)
922        );
923        let out: Value = serde_json::from_str(&bridge_call(&big)).unwrap();
924        assert_eq!(out["error"]["code"], "resource_limit");
925    }
926
927    #[test]
928    fn solve_without_payload_is_invalid_request() {
929        let out = call(json!({"api_version": 1, "command": "solve"}));
930        assert_eq!(out["error"]["code"], "invalid_request");
931    }
932
933    #[test]
934    fn profile_commands_without_payload_are_invalid_requests() {
935        for command in ["profile.validate", "profile.normalize"] {
936            let out = call(json!({"api_version": 1, "command": command}));
937            assert_eq!(out["error"]["code"], "invalid_request", "{command}: {out}");
938            assert!(
939                out["error"]["message"]
940                    .as_str()
941                    .unwrap()
942                    .contains("ProfileData"),
943                "{command}: {out}"
944            );
945        }
946    }
947
948    #[test]
949    fn capabilities_lists_profile_commands_and_gates_import_on_the_feature() {
950        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
951        let commands: Vec<String> =
952            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
953        assert!(commands.contains(&"profile.validate".to_string()));
954        assert!(commands.contains(&"profile.normalize".to_string()));
955        assert_eq!(
956            commands.contains(&"profile.import_a7p".to_string()),
957            cfg!(feature = "profile-import"),
958            "profile.import_a7p must be listed exactly when compiled in"
959        );
960        assert_eq!(
961            commands.contains(&"bc5d.info".to_string()),
962            cfg!(not(target_arch = "wasm32")),
963            "bc5d.info must be listed exactly when the build has filesystem access"
964        );
965    }
966
967    #[cfg(not(target_arch = "wasm32"))]
968    #[test]
969    fn bc5d_info_without_payload_or_with_missing_file_fails_cleanly() {
970        let out = call(json!({"api_version": 1, "command": "bc5d.info"}));
971        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
972
973        let out = call(json!({
974            "api_version": 1,
975            "command": "bc5d.info",
976            "request": {"path": "/nonexistent/bc5d_308.bin"}
977        }));
978        assert_eq!(out["error"]["code"], "command_failed", "{out}");
979        assert!(
980            out["error"]["message"]
981                .as_str()
982                .unwrap()
983                .contains("not a usable BC5D table"),
984            "{out}"
985        );
986    }
987
988    #[cfg(feature = "profile-import")]
989    #[test]
990    fn base64_decoder_round_trips_and_rejects_garbage() {
991        // RFC 4648 test vectors.
992        for (text, bytes) in [
993            ("", &b""[..]),
994            ("Zg==", b"f"),
995            ("Zm8=", b"fo"),
996            ("Zm9v", b"foo"),
997            ("Zm9vYg==", b"foob"),
998            ("Zm9vYmE=", b"fooba"),
999            ("Zm9vYmFy", b"foobar"),
1000        ] {
1001            assert_eq!(decode_base64(text).unwrap(), bytes, "{text}");
1002        }
1003        assert!(decode_base64("Zm9v\n").is_err(), "whitespace is rejected");
1004        assert!(decode_base64("Zg=X").is_err(), "inner padding is rejected");
1005        assert!(decode_base64("Z").is_err(), "4n+1 length is rejected");
1006        assert!(decode_base64("Zm9v!").is_err(), "non-alphabet byte is rejected");
1007    }
1008
1009    /// `card.pdf` must be listed exactly when the `pdf` feature is compiled in, and be an
1010    /// honest `unknown_command` otherwise — the same rule `profile.import_a7p` follows. The
1011    /// pdf-absent half of this only runs under `--no-default-features --features bridge`.
1012    #[test]
1013    fn capabilities_gates_card_pdf_on_the_pdf_feature() {
1014        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1015        let commands: Vec<String> =
1016            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1017        assert_eq!(
1018            commands.contains(&"card.pdf".to_string()),
1019            cfg!(feature = "pdf"),
1020            "card.pdf must be listed exactly when compiled in: {out}"
1021        );
1022        let features: Vec<String> =
1023            serde_json::from_value(out["result"]["features"].clone()).unwrap();
1024        assert_eq!(
1025            features.contains(&"pdf".to_string()),
1026            cfg!(feature = "pdf"),
1027            "the command list and the feature list must agree: {out}"
1028        );
1029    }
1030
1031    #[cfg(not(feature = "pdf"))]
1032    #[test]
1033    fn card_pdf_is_an_unknown_command_without_the_pdf_feature() {
1034        let out = call(json!({
1035            "api_version": 1,
1036            "command": "card.pdf",
1037            "request": {
1038                "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
1039                "mass": 175.0, "diameter": 0.308,
1040                "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0
1041            }
1042        }));
1043        assert_eq!(out["error"]["code"], "unknown_command", "{out}");
1044    }
1045
1046    /// The `pdf` presentation block must survive a build that cannot render it: an app
1047    /// stores one request per card and replays it against `card.range_table` too, so a
1048    /// pdf-less engine has to ACCEPT the field rather than reject it as unknown.
1049    #[test]
1050    fn the_pdf_presentation_block_is_accepted_by_the_on_screen_card_in_every_build() {
1051        let out = call(json!({
1052            "api_version": 1,
1053            "command": "card.range_table",
1054            "request": {
1055                "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
1056                "mass": 175.0, "diameter": 0.308,
1057                "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0,
1058                "pdf": {"title": "Stored Card", "target_speed": 8.0, "font_preset": "large"}
1059            }
1060        }));
1061        assert_eq!(out["ok"], true, "{out}");
1062        assert_eq!(out["result"]["kind"], "range_table", "{out}");
1063    }
1064
1065    #[cfg(feature = "pdf")]
1066    #[test]
1067    fn card_pdf_without_payload_is_invalid_request() {
1068        let out = call(json!({"api_version": 1, "command": "card.pdf"}));
1069        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
1070        assert!(
1071            out["error"]["message"].as_str().unwrap().contains("card v1 document"),
1072            "{out}"
1073        );
1074    }
1075
1076    /// The output cap's boundary, both sides. Generating a genuinely over-cap dope card
1077    /// would take tens of thousands of rows, so the predicate is tested directly — see
1078    /// `pdf_over_cap_error`'s own comment.
1079    #[cfg(feature = "pdf")]
1080    #[test]
1081    fn pdf_output_cap_refuses_only_over_the_limit() {
1082        assert!(pdf_over_cap_error(0, 0, 0).is_none());
1083        assert!(
1084            pdf_over_cap_error(MAX_PDF_BYTES, 6, 1).is_none(),
1085            "a document exactly at the cap fits"
1086        );
1087        let envelope: Value =
1088            serde_json::from_str(&pdf_over_cap_error(MAX_PDF_BYTES + 1, 6, 1).expect("over cap"))
1089                .unwrap();
1090        assert_eq!(envelope["ok"], false);
1091        assert_eq!(envelope["error"]["code"], "resource_limit");
1092        let message = envelope["error"]["message"].as_str().unwrap();
1093        assert!(message.contains("dope card"), "{envelope}");
1094        // What is true of the document, not advice about controls a saved card lacks.
1095        assert!(message.contains("6 rows"), "{envelope}");
1096        assert!(message.contains("1 pages"), "{envelope}");
1097        for absent in ["coarsen", "shorten"] {
1098            assert!(!message.contains(absent), "{envelope}");
1099        }
1100    }
1101
1102    #[cfg(feature = "pdf")]
1103    #[test]
1104    fn base64_encoder_matches_the_rfc_4648_vectors() {
1105        for (bytes, text) in [
1106            (&b""[..], ""),
1107            (b"f", "Zg=="),
1108            (b"fo", "Zm8="),
1109            (b"foo", "Zm9v"),
1110            (b"foob", "Zm9vYg=="),
1111            (b"fooba", "Zm9vYmE="),
1112            (b"foobar", "Zm9vYmFy"),
1113        ] {
1114            assert_eq!(encode_base64(bytes), text, "{bytes:?}");
1115        }
1116        // Full-byte-range coverage: the >> 18 / >> 12 / >> 6 masking must not sign- or
1117        // width-mangle a high byte, which is most of a PDF's content.
1118        assert_eq!(encode_base64(&[0xff, 0xff, 0xff]), "////");
1119        assert_eq!(encode_base64(&[0x00, 0x00, 0x00]), "AAAA");
1120        assert_eq!(encode_base64(&[0xfb, 0xff, 0xbf]), "+/+/");
1121    }
1122
1123    /// The encoder and the (profile-import) decoder must be inverses — the property that
1124    /// makes `pdf_base64` a lossless transport for a binary document.
1125    #[cfg(all(feature = "pdf", feature = "profile-import"))]
1126    #[test]
1127    fn base64_encode_decode_round_trips_arbitrary_bytes() {
1128        for len in 0..=32usize {
1129            let bytes: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(37).wrapping_add(11)).collect();
1130            let decoded = decode_base64(&encode_base64(&bytes)).expect("own output decodes");
1131            assert_eq!(decoded, bytes, "len {len}");
1132        }
1133    }
1134
1135    #[test]
1136    fn solve_with_bad_schema_carries_typed_details() {
1137        let out = call(json!({
1138            "api_version": 1,
1139            "command": "solve",
1140            "request": {"schema_version": 1, "unknown_field": true}
1141        }));
1142        assert_eq!(out["error"]["code"], "command_failed");
1143        // The solve-json envelope rides along losslessly.
1144        assert_eq!(out["error"]["details"]["status"], "error");
1145    }
1146}