1#[cfg(feature = "ffi")]
37pub mod ffi;
38
39use serde::{Deserialize, Serialize};
40use serde_json::{json, Value};
41use std::panic::{catch_unwind, AssertUnwindSafe};
42
43pub const BRIDGE_API_VERSION: u32 = 1;
45
46pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
48
49const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
50
51fn 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 #[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 #[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#[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
138fn 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
148pub 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
232fn 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
273fn 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#[cfg(feature = "pdf")]
331pub const MAX_PDF_BYTES: usize = 4 * 1024 * 1024;
332
333#[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#[cfg(feature = "pdf")]
362const STORED_CARD_KEY: &str = "stored_card";
363
364#[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 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#[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 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
509fn 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
515fn 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
539fn 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
568fn 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#[cfg(not(target_arch = "wasm32"))]
590#[derive(Debug, Deserialize)]
591#[serde(deny_unknown_fields)]
592struct Bc5dInfoRequest {
593 path: String,
594}
595
596#[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 "valid": true,
657 "crc_ok": true,
658 "format_version": table.version(),
659 "caliber": table.caliber(),
660 "caliber_key": table.caliber_key(),
662 "api_version": table.api_version(),
663 "generated_timestamp": table.timestamp(),
664 "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#[cfg(feature = "profile-import")]
683pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
684
685#[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#[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 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#[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 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 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 #[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 #[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 #[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 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 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 #[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 assert_eq!(out["error"]["details"]["status"], "error");
1145 }
1146}