1use serde_json::{json, Value};
17use ssp2::segment::{decode_rows_segment, encode_rows_segment};
18use ssp2::{
19 decode_message, encode_message, parse_control, render_message, render_rows_segment,
20 ControlMessage,
21};
22use syncular_client::{
23 ClientDiagnosticsRequest, ClientLimits, CommandEffects, CommitOutcomeQuery,
24 LocalDataPurgeInput, LocalDataRebootstrapInput, Mutation, ResolveCommitOutcomeInput,
25 SyncClient, Transport, WindowBase, WindowCoverage,
26};
27
28pub fn bytes_to_hex(bytes: &[u8]) -> String {
31 syncular_client::values::bytes_to_hex(bytes)
32}
33
34pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
35 syncular_client::values::hex_to_bytes(hex)
36}
37
38pub fn bytes_value(bytes: &[u8]) -> Value {
39 json!({ "$bytes": bytes_to_hex(bytes) })
40}
41
42pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
43 let hex = value
44 .and_then(|v| v.get("$bytes"))
45 .and_then(Value::as_str)
46 .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
47 hex_to_bytes(hex)
48}
49
50pub type CommandError = (String, String);
52
53#[derive(Debug, Default, Clone)]
57pub struct CreateEffects {
58 pub signed_urls: bool,
61 pub security_preflight_pending: bool,
77}
78
79fn client_err(message: String) -> CommandError {
80 let code = message
81 .split_once(':')
82 .map(|(candidate, _)| candidate)
83 .filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
84 .unwrap_or("client.failed");
85 (code.to_owned(), message)
86}
87
88fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
89 client
90 .as_mut()
91 .ok_or_else(|| client_err("no client instance created".to_owned()))
92}
93
94pub fn parse_limits(value: Option<&Value>) -> ClientLimits {
95 let mut limits = ClientLimits::default();
96 let Some(object) = value.and_then(Value::as_object) else {
97 return limits;
98 };
99 limits.limit_commits = object
100 .get("limitCommits")
101 .and_then(Value::as_i64)
102 .map(|v| v as i32);
103 limits.limit_snapshot_rows = object
104 .get("limitSnapshotRows")
105 .and_then(Value::as_i64)
106 .map(|v| v as i32);
107 limits.max_snapshot_pages = object
108 .get("maxSnapshotPages")
109 .and_then(Value::as_i64)
110 .map(|v| v as i32);
111 limits.accept = object
112 .get("accept")
113 .and_then(Value::as_u64)
114 .map(|v| v as u8);
115 limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
116 limits.outcome_retention_max_entries = object
117 .get("outcomeRetentionMaxEntries")
118 .and_then(Value::as_u64)
119 .map(|value| value as usize);
120 limits
121}
122
123pub fn parse_encryption(
127 value: &Value,
128) -> Result<syncular_client::values::EncryptionConfig, String> {
129 let mut config = syncular_client::values::EncryptionConfig::default();
130 let Some(keys) = value.get("keys").and_then(Value::as_object) else {
131 return Ok(config);
132 };
133 for (key_id, key_val) in keys {
134 let bytes =
135 value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
136 if bytes.len() != 32 {
137 return Err(format!(
138 "encryption key {key_id:?} must be 32 bytes, got {}",
139 bytes.len()
140 ));
141 }
142 config.keys.insert(key_id.clone(), bytes);
143 }
144 if let Some(columns) = value.get("keyIdColumns") {
145 let columns = columns
146 .as_object()
147 .ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
148 for (table, column) in columns {
149 let column = column.as_str().ok_or_else(|| {
150 format!("encryption keyIdColumns entry for {table:?} must be a string")
151 })?;
152 if column.is_empty() {
153 return Err(format!(
154 "encryption keyIdColumns entry for {table:?} must not be empty"
155 ));
156 }
157 config
158 .key_id_columns
159 .insert(table.clone(), column.to_owned());
160 }
161 }
162 Ok(config)
163}
164
165pub fn parse_headers(value: &Value) -> Result<Vec<(String, String)>, String> {
170 let object = value.as_object().ok_or_else(|| {
171 "sync.invalid_request: headers must be an object of string values".to_owned()
172 })?;
173 let mut headers = Vec::with_capacity(object.len());
174 for (name, value) in object {
175 let value = value
176 .as_str()
177 .ok_or_else(|| format!("sync.invalid_request: header {name:?} must be a string"))?;
178 headers.push((name.clone(), value.to_owned()));
179 }
180 Ok(headers)
181}
182
183pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
184 let list = value
185 .and_then(Value::as_array)
186 .ok_or_else(|| "mutations must be a list".to_owned())?;
187 let mut out = Vec::with_capacity(list.len());
188 for entry in list {
189 let op = entry
190 .get("op")
191 .and_then(Value::as_str)
192 .ok_or_else(|| "mutation missing op".to_owned())?;
193 let table = entry
194 .get("table")
195 .and_then(Value::as_str)
196 .ok_or_else(|| "mutation missing table".to_owned())?
197 .to_owned();
198 let base_version = entry.get("baseVersion").and_then(Value::as_i64);
199 match op {
200 "upsert" => {
201 let mut values = entry
202 .get("values")
203 .and_then(Value::as_object)
204 .cloned()
205 .ok_or_else(|| "upsert missing values".to_owned())?;
206 decode_bigint_members(&mut values)?;
207 out.push(Mutation::Upsert {
208 table,
209 values,
210 base_version,
211 });
212 }
213 "delete" => {
214 let row_id = entry
215 .get("rowId")
216 .and_then(Value::as_str)
217 .ok_or_else(|| "delete missing rowId".to_owned())?
218 .to_owned();
219 out.push(Mutation::Delete {
220 table,
221 row_id,
222 base_version,
223 });
224 }
225 other => return Err(format!("unknown mutation op {other:?}")),
226 }
227 }
228 Ok(out)
229}
230
231fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
232 for value in values.values_mut() {
233 let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
234 continue;
235 };
236 let integer = decimal
237 .parse::<i64>()
238 .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
239 *value = Value::from(integer);
240 }
241 Ok(())
242}
243
244fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
245 match value {
246 Some(v) => syncular_client::values::json_to_scope_map(v),
247 None => Ok(Vec::new()),
248 }
249}
250
251fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
254 let object = value
255 .and_then(Value::as_object)
256 .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
257 let table = object
258 .get("table")
259 .and_then(Value::as_str)
260 .ok_or_else(|| "window base missing table".to_owned())?
261 .to_owned();
262 let variable = object
263 .get("variable")
264 .and_then(Value::as_str)
265 .ok_or_else(|| "window base missing variable".to_owned())?
266 .to_owned();
267 let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
268 let params = object
269 .get("params")
270 .and_then(Value::as_str)
271 .map(str::to_owned);
272 Ok(WindowBase {
273 table,
274 variable,
275 fixed_scopes,
276 params,
277 })
278}
279
280#[cfg(feature = "crdt-yjs")]
284fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
285 let table = params
286 .get("table")
287 .and_then(Value::as_str)
288 .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
289 .to_owned();
290 let row_id = params
291 .get("rowId")
292 .and_then(Value::as_str)
293 .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
294 .to_owned();
295 let column = params
296 .get("column")
297 .and_then(Value::as_str)
298 .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
299 .to_owned();
300 let name = params
301 .get("name")
302 .and_then(Value::as_str)
303 .unwrap_or("text")
304 .to_owned();
305 Ok((table, row_id, column, name))
306}
307
308pub fn dispatch<T: Transport>(
318 transport: &mut T,
319 client: &mut Option<SyncClient>,
320 effects: &mut CreateEffects,
321 method: &str,
322 params: &Value,
323) -> Result<Value, CommandError> {
324 if method == "create" {
325 let requests_preflight = params
332 .get("securityPreflight")
333 .and_then(Value::as_bool)
334 .unwrap_or(false);
335 let preflight_engaged = client.as_ref().map_or(
336 effects.security_preflight_pending,
337 SyncClient::security_preflight,
338 );
339 if preflight_engaged && !requests_preflight {
340 return Err((
341 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
342 "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity".to_owned(),
343 ));
344 }
345 } else {
346 let allowed_during_preflight = matches!(
347 method,
348 "securityLifecycle"
349 | "beginSecurityPreflight"
350 | "activateSecurity"
351 | "purgeLocalData"
352 | "localRevision"
353 | "statusSnapshot"
354 | "shutdown"
355 );
356 if client
357 .as_ref()
358 .is_some_and(|running| running.security_preflight())
359 && !allowed_during_preflight
360 {
361 return Err((
362 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
363 "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data".to_owned(),
364 ));
365 }
366 }
367 match method {
368 "create" => {
369 let client_id = params
370 .get("clientId")
371 .and_then(Value::as_str)
372 .map(str::to_owned);
373 let schema = params
374 .get("schema")
375 .ok_or_else(|| client_err("create missing schema".to_owned()))?;
376 let limits = parse_limits(params.get("limits"));
377 let mut instance = match params.get("dbPath").and_then(Value::as_str) {
381 Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
382 .map_err(client_err)?,
383 None => {
384 SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
385 }
386 };
387 if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
389 instance.set_now_ms(now_ms);
390 }
391 effects.signed_urls = params
394 .get("signedUrls")
395 .and_then(Value::as_bool)
396 .unwrap_or(false);
397 let security_preflight = params
401 .get("securityPreflight")
402 .and_then(Value::as_bool)
403 .unwrap_or(false);
404 if instance.security_preflight() && !security_preflight {
412 return Err((
413 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
414 "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity".to_owned(),
415 ));
416 }
417 if security_preflight && params.get("encryption").is_some() {
418 return Err(client_err(
419 "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
420 .to_owned(),
421 ));
422 }
423 if let Some(enc) = params.get("encryption") {
424 let config = parse_encryption(enc).map_err(client_err)?;
425 instance.set_encryption(config);
426 }
427 if security_preflight {
428 instance.begin_security_preflight();
429 }
430 effects.security_preflight_pending = security_preflight;
431 *client = Some(instance);
432 Ok(json!({}))
433 }
434 "securityLifecycle" => Ok(json!({
435 "state": need_client(client)?.security_lifecycle()
436 })),
437 "beginSecurityPreflight" => {
438 let running = need_client(client)?;
439 running.disconnect_realtime(transport);
440 running.begin_security_preflight();
441 effects.security_preflight_pending = true;
442 Ok(json!({}))
443 }
444 "activateSecurity" => {
445 let encryption = match params.get("encryption") {
446 Some(value) => parse_encryption(value).map_err(client_err)?,
447 None => syncular_client::values::EncryptionConfig::default(),
448 };
449 if let Some(headers) = params.get("headers") {
455 parse_headers(headers).map_err(client_err)?;
456 }
457 need_client(client)?
458 .activate_security(encryption)
459 .map_err(client_err)?;
460 effects.security_preflight_pending = false;
461 Ok(json!({}))
462 }
463 "shutdown" => {
464 if let Some(running) = client.as_mut() {
465 effects.security_preflight_pending = running.security_preflight();
470 running.disconnect_realtime(transport);
471 running.seal_security_on_teardown();
475 }
476 *client = None;
477 Ok(json!({}))
478 }
479 "subscribe" => {
480 let id = params
481 .get("id")
482 .and_then(Value::as_str)
483 .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
484 .to_owned();
485 let table = params
486 .get("table")
487 .and_then(Value::as_str)
488 .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
489 .to_owned();
490 let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
491 let sub_params = params
492 .get("params")
493 .and_then(Value::as_str)
494 .map(str::to_owned);
495 need_client(client)?
496 .subscribe(id, table, scopes, sub_params)
497 .map_err(client_err)?;
498 Ok(json!({}))
499 }
500 "unsubscribe" => {
501 let id = params
502 .get("id")
503 .and_then(Value::as_str)
504 .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
505 need_client(client)?.unsubscribe(id);
506 Ok(json!({}))
507 }
508 "setWindow" => {
509 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
510 let units: Vec<String> = params
511 .get("units")
512 .and_then(Value::as_array)
513 .map(|arr| {
514 arr.iter()
515 .filter_map(|v| v.as_str().map(str::to_owned))
516 .collect()
517 })
518 .unwrap_or_default();
519 let command_effects = need_client(client)?
520 .set_window(&base, &units)
521 .map_err(client_err)?;
522 Ok(json!({ "effects": command_effects }))
523 }
524 "windowState" => {
525 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
526 let state = need_client(client)?.window_state(&base);
527 Ok(json!({ "units": state.units, "pending": state.pending }))
528 }
529 "mutate" => {
530 let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
531 let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
532 Ok(json!({
533 "clientCommitId": id,
534 "effects": CommandEffects::interactive()
535 }))
536 }
537 "patch" => {
538 let table = params
539 .get("table")
540 .and_then(Value::as_str)
541 .ok_or_else(|| client_err("patch missing table".to_owned()))?;
542 let row_id = params
543 .get("rowId")
544 .and_then(Value::as_str)
545 .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
546 let mut partial = params
547 .get("partial")
548 .and_then(Value::as_object)
549 .cloned()
550 .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
551 decode_bigint_members(&mut partial).map_err(client_err)?;
552 let base_version = params.get("baseVersion").and_then(Value::as_i64);
553 let id = need_client(client)?
554 .patch(table, row_id, partial, base_version)
555 .map_err(client_err)?;
556 Ok(json!({
557 "clientCommitId": id,
558 "effects": CommandEffects::interactive()
559 }))
560 }
561 "purgeLocalData" => {
562 let input: LocalDataPurgeInput =
563 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
564 client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
565 })?)
566 .map_err(|error| {
567 client_err(format!(
568 "sync.invalid_request: invalid purgeLocalData input: {error}"
569 ))
570 })?;
571 let result = need_client(client)?
572 .purge_local_data(&input)
573 .map_err(client_err)?;
574 serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
575 }
576 "rebootstrapLocalData" => {
577 let input: LocalDataRebootstrapInput =
578 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
579 client_err(
580 "sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
581 )
582 })?)
583 .map_err(|error| {
584 client_err(format!(
585 "sync.invalid_request: invalid rebootstrapLocalData input: {error}"
586 ))
587 })?;
588 let result = need_client(client)?
589 .rebootstrap_local_data(&input)
590 .map_err(client_err)?;
591 Ok(json!({
592 "alreadyApplied": result.already_applied,
593 "retainedCommits": result.retained_commits,
594 "resetSubscriptions": result.reset_subscriptions,
595 "effects": if result.already_applied {
596 CommandEffects::none()
597 } else {
598 CommandEffects::interactive()
599 }
600 }))
601 }
602 "sync" => {
603 let outcome = need_client(client)?.sync(transport);
604 Ok(outcome.to_json())
605 }
606 "syncUntilIdle" => {
607 let max_rounds = params
608 .get("maxRounds")
609 .and_then(Value::as_u64)
610 .map(|v| v as u32);
611 let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
612 Ok(outcome.to_json())
613 }
614 "readRows" => {
615 let table = params
616 .get("table")
617 .and_then(Value::as_str)
618 .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
619 let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
620 Ok(json!({ "rows": rows }))
621 }
622 "query" => {
623 let sql = params
627 .get("sql")
628 .and_then(Value::as_str)
629 .ok_or_else(|| client_err("query missing sql".to_owned()))?;
630 let bind = match params.get("params") {
631 Some(Value::Array(list)) => list.clone(),
632 None | Some(Value::Null) => Vec::new(),
633 Some(_) => return Err(client_err("query params must be a list".to_owned())),
634 };
635 let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
636 Ok(json!({ "rows": rows }))
637 }
638 "querySnapshot" => {
639 let sql = params
640 .get("sql")
641 .and_then(Value::as_str)
642 .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
643 let bind = match params.get("params") {
644 Some(Value::Array(list)) => list.clone(),
645 None | Some(Value::Null) => Vec::new(),
646 Some(_) => {
647 return Err(client_err("querySnapshot params must be a list".to_owned()))
648 }
649 };
650 let mut coverage = Vec::new();
651 for entry in params
652 .get("coverage")
653 .and_then(Value::as_array)
654 .into_iter()
655 .flatten()
656 {
657 let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
658 let units = entry
659 .get("units")
660 .and_then(Value::as_array)
661 .map(|values| {
662 values
663 .iter()
664 .filter_map(|value| value.as_str().map(str::to_owned))
665 .collect()
666 })
667 .unwrap_or_default();
668 coverage.push(WindowCoverage { base, units });
669 }
670 let snapshot = need_client(client)?
671 .query_snapshot(sql, &bind, &coverage)
672 .map_err(client_err)?;
673 Ok(serde_json::to_value(snapshot).expect("snapshot serializes"))
674 }
675 "localRevision" => Ok(json!({
676 "revision": need_client(client)?.local_revision().to_string()
677 })),
678 "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
679 .expect("status serializes")),
680 "diagnosticsSnapshot" => {
681 let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
682 .map_err(|error| {
683 client_err(format!(
684 "sync.invalid_request: invalid diagnostics request: {error}"
685 ))
686 })?;
687 let snapshot = need_client(client)?
688 .diagnostics_snapshot(&request)
689 .map_err(client_err)?;
690 Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
691 }
692 "drainChangeBatches" => Ok(json!({
697 "batches": need_client(client)?.drain_change_batches()
698 })),
699 "drainSyncIntents" => Ok(json!({
700 "intents": need_client(client)?.drain_sync_intents()
701 })),
702 #[cfg(feature = "crdt-yjs")]
708 "crdtText" => {
709 let (table, row_id, column, name) = crdt_target(params)?;
710 let text = need_client(client)?
711 .crdt_text(&table, &row_id, &column, &name)
712 .map_err(client_err)?;
713 Ok(json!({ "text": text }))
714 }
715 #[cfg(feature = "crdt-yjs")]
716 "crdtInsertText" => {
717 let (table, row_id, column, name) = crdt_target(params)?;
718 let index = params
719 .get("index")
720 .and_then(Value::as_u64)
721 .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
722 as u32;
723 let value = params
724 .get("value")
725 .and_then(Value::as_str)
726 .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
727 let id = need_client(client)?
728 .crdt_insert_text(&table, &row_id, &column, &name, index, value)
729 .map_err(client_err)?;
730 Ok(json!({ "clientCommitId": id }))
731 }
732 #[cfg(feature = "crdt-yjs")]
733 "crdtDeleteText" => {
734 let (table, row_id, column, name) = crdt_target(params)?;
735 let index = params
736 .get("index")
737 .and_then(Value::as_u64)
738 .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
739 as u32;
740 let len = params
741 .get("len")
742 .and_then(Value::as_u64)
743 .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
744 as u32;
745 let id = need_client(client)?
746 .crdt_delete_text(&table, &row_id, &column, &name, index, len)
747 .map_err(client_err)?;
748 Ok(json!({ "clientCommitId": id }))
749 }
750 #[cfg(feature = "crdt-yjs")]
751 "crdtApplyUpdate" => {
752 let (table, row_id, column, _name) = crdt_target(params)?;
753 let update = value_bytes(params.get("update")).map_err(client_err)?;
754 let id = need_client(client)?
755 .crdt_apply_update(&table, &row_id, &column, &update)
756 .map_err(client_err)?;
757 Ok(json!({ "clientCommitId": id }))
758 }
759 #[cfg(not(feature = "crdt-yjs"))]
760 "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
761 "client.crdt_unavailable".to_owned(),
762 "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
763 )),
764
765 "uploadBlob" => {
766 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
767 let media_type = params
768 .get("mediaType")
769 .and_then(Value::as_str)
770 .map(str::to_owned);
771 let name = params
772 .get("name")
773 .and_then(Value::as_str)
774 .map(str::to_owned);
775 let reference = need_client(client)?
776 .upload_blob(&bytes, media_type, name)
777 .map_err(client_err)?;
778 Ok(json!({ "ref": reference }))
779 }
780 "fetchBlob" => {
781 let blob = params
782 .get("blob")
783 .and_then(Value::as_str)
784 .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
785 .to_owned();
786 let value = need_client(client)?.fetch_blob(transport, &blob)?;
789 Ok(json!({ "blob": value }))
790 }
791 "conflicts" => {
792 let conflicts = need_client(client)?.conflicts().to_vec();
793 Ok(json!({ "conflicts": conflicts }))
794 }
795 "rejections" => {
796 let rejections = need_client(client)?.rejections().to_vec();
797 Ok(json!({ "rejections": rejections }))
798 }
799 "commitOutcome" => {
800 let client_commit_id = params
801 .get("clientCommitId")
802 .and_then(Value::as_str)
803 .ok_or_else(|| {
804 client_err(
805 "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
806 )
807 })?;
808 let outcome = need_client(client)?
809 .commit_outcome(client_commit_id)
810 .map_err(client_err)?;
811 Ok(json!({ "outcome": outcome }))
812 }
813 "commitOutcomes" => {
814 let query = serde_json::from_value::<CommitOutcomeQuery>(
815 params.get("query").cloned().unwrap_or_else(|| json!({})),
816 )
817 .map_err(|error| {
818 client_err(format!(
819 "sync.invalid_request: invalid commit outcome query: {error}"
820 ))
821 })?;
822 let outcomes = need_client(client)?
823 .commit_outcomes(query)
824 .map_err(client_err)?;
825 Ok(json!({ "outcomes": outcomes }))
826 }
827 "resolveCommitOutcome" => {
828 let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
829 params.get("input").cloned().ok_or_else(|| {
830 client_err(
831 "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
832 )
833 })?,
834 )
835 .map_err(|error| {
836 client_err(format!(
837 "sync.invalid_request: invalid outcome resolution: {error}"
838 ))
839 })?;
840 let outcome = need_client(client)?
841 .resolve_commit_outcome(input)
842 .map_err(client_err)?;
843 Ok(json!({ "outcome": outcome }))
844 }
845 "pendingCommitIds" => {
846 let ids = need_client(client)?.pending_commit_ids();
847 Ok(json!({ "ids": ids }))
848 }
849 "subscriptionState" => {
850 let id = params
851 .get("id")
852 .and_then(Value::as_str)
853 .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
854 let state = need_client(client)?.subscription_state(id);
855 Ok(json!({ "state": state }))
856 }
857 "schemaFloor" => {
858 let floor = need_client(client)?.schema_floor().cloned();
859 Ok(json!({ "floor": floor }))
860 }
861 "leaseState" => {
862 let lease = need_client(client)?.lease_state().cloned();
863 Ok(json!({ "lease": lease }))
864 }
865 "upgrading" => {
866 let value = need_client(client)?.upgrading();
868 Ok(json!({ "value": value }))
869 }
870 "recreateWithSchema" => {
871 let schema = params
875 .get("schema")
876 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
877 need_client(client)?
878 .recreate_with_schema(schema)
879 .map_err(client_err)?;
880 Ok(json!({}))
881 }
882 "connectRealtime" => {
883 need_client(client)?
884 .connect_realtime(transport)
885 .map_err(client_err)?;
886 Ok(json!({}))
887 }
888 "disconnectRealtime" => {
889 need_client(client)?.disconnect_realtime(transport);
890 Ok(json!({}))
891 }
892 "syncNeeded" => {
893 let value = need_client(client)?.sync_needed();
894 Ok(json!({ "value": value }))
895 }
896 "setPresence" => {
897 let scope_key = params
898 .get("scopeKey")
899 .and_then(Value::as_str)
900 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
901 .to_owned();
902 let doc = params.get("doc");
904 let doc_ref = match doc {
905 None | Some(Value::Null) => None,
906 Some(v) => Some(v),
907 };
908 need_client(client)?
909 .set_presence(transport, &scope_key, doc_ref)
910 .map_err(client_err)?;
911 Ok(json!({}))
912 }
913 "presence" => {
914 let scope_key = params
915 .get("scopeKey")
916 .and_then(Value::as_str)
917 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
918 let peers = need_client(client)?.presence(scope_key);
919 Ok(json!({ "peers": peers }))
920 }
921
922 "messageRoundtrip" => {
924 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
925 match decode_message(&bytes) {
926 Ok(message) => Ok(json!({
927 "ok": true,
928 "bytes": bytes_value(&encode_message(&message)),
929 "renderedJson": render_message(&message).to_string(),
930 })),
931 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
932 }
933 }
934 "segmentRoundtrip" => {
935 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
936 match decode_rows_segment(&bytes) {
937 Ok(segment) => Ok(json!({
938 "ok": true,
939 "bytes": bytes_value(&encode_rows_segment(&segment)),
940 "renderedJson": render_rows_segment(&segment).to_string(),
941 })),
942 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
943 }
944 }
945 "realtimeKnown" => {
946 let text = params
947 .get("text")
948 .and_then(Value::as_str)
949 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
950 let known = matches!(
951 parse_control(text),
952 Ok(ControlMessage::Hello { .. })
953 | Ok(ControlMessage::Wake { .. })
954 | Ok(ControlMessage::Heartbeat { .. })
955 | Ok(ControlMessage::Presence { .. })
956 );
957 Ok(json!({ "value": known }))
958 }
959
960 other => Err(client_err(format!("unknown method {other:?}"))),
961 }
962}
963
964#[cfg(test)]
965mod tests {
966 use serde_json::{json, Value};
967 use syncular_client::{
968 SegmentRequest, SyncClient, Transport, TransportError, SECURITY_PREFLIGHT_REQUIRED_CODE,
969 };
970
971 use super::{dispatch, parse_encryption, parse_headers, CreateEffects};
972
973 #[derive(Default)]
974 struct NoNetwork {
975 realtime_connects: usize,
976 realtime_closes: usize,
977 }
978
979 impl Transport for NoNetwork {
980 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
981 Err(TransportError::new("sync.transport_failed", "offline"))
982 }
983
984 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
985 Err(TransportError::new("sync.transport_failed", "offline"))
986 }
987
988 fn download_segment(
989 &mut self,
990 _request: &SegmentRequest,
991 ) -> Result<Vec<u8>, TransportError> {
992 Err(TransportError::new("sync.transport_failed", "offline"))
993 }
994
995 fn realtime_connect(&mut self) -> Result<(), TransportError> {
996 self.realtime_connects += 1;
997 Ok(())
998 }
999
1000 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1001 Ok(())
1002 }
1003
1004 fn realtime_close(&mut self) -> Result<(), TransportError> {
1005 self.realtime_closes += 1;
1006 Ok(())
1007 }
1008 }
1009
1010 fn schema() -> Value {
1011 json!({
1012 "version": 1,
1013 "tables": [{
1014 "name": "todos",
1015 "columns": [
1016 { "name": "id", "type": "string", "nullable": false },
1017 { "name": "list_id", "type": "string", "nullable": false }
1018 ],
1019 "primaryKey": "id",
1020 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1021 }]
1022 })
1023 }
1024
1025 #[test]
1026 fn native_command_realtime_connection_is_idempotent() {
1027 let mut transport = NoNetwork::default();
1028 let mut client: Option<SyncClient> = None;
1029 let mut effects = CreateEffects::default();
1030 dispatch(
1031 &mut transport,
1032 &mut client,
1033 &mut effects,
1034 "create",
1035 &json!({ "schema": schema() }),
1036 )
1037 .expect("create client");
1038
1039 for _ in 0..2 {
1040 dispatch(
1041 &mut transport,
1042 &mut client,
1043 &mut effects,
1044 "connectRealtime",
1045 &json!({}),
1046 )
1047 .expect("idempotent connect command");
1048 }
1049 assert_eq!(transport.realtime_connects, 1);
1050
1051 dispatch(
1052 &mut transport,
1053 &mut client,
1054 &mut effects,
1055 "disconnectRealtime",
1056 &json!({}),
1057 )
1058 .expect("disconnect command");
1059 dispatch(
1060 &mut transport,
1061 &mut client,
1062 &mut effects,
1063 "disconnectRealtime",
1064 &json!({}),
1065 )
1066 .expect("idempotent disconnect command");
1067 assert_eq!(transport.realtime_closes, 1);
1068 dispatch(
1069 &mut transport,
1070 &mut client,
1071 &mut effects,
1072 "connectRealtime",
1073 &json!({}),
1074 )
1075 .expect("deliberate reconnect command");
1076 assert_eq!(transport.realtime_connects, 2);
1077 }
1078
1079 #[test]
1080 fn parses_portable_encryption_keyring_and_key_id_columns() {
1081 let key_hex = "2a".repeat(32);
1082 let config = parse_encryption(&json!({
1083 "keys": { "practice-key-v1": { "$bytes": key_hex } },
1084 "keyIdColumns": { "patients": "encryption_key_id" }
1085 }))
1086 .expect("portable keyring parses");
1087 assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1088 assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1089 }
1090
1091 #[test]
1092 fn rejects_non_string_key_id_columns() {
1093 let error = parse_encryption(&json!({
1094 "keys": {},
1095 "keyIdColumns": { "patients": 7 }
1096 }))
1097 .expect_err("invalid selector must fail");
1098 assert!(error.contains("must be a string"), "{error}");
1099 }
1100
1101 #[test]
1102 fn native_command_hosts_reject_subscription_identity_rebinds() {
1103 let mut transport = NoNetwork::default();
1104 let mut client: Option<SyncClient> = None;
1105 let mut effects = CreateEffects::default();
1106 dispatch(
1107 &mut transport,
1108 &mut client,
1109 &mut effects,
1110 "create",
1111 &json!({ "schema": schema() }),
1112 )
1113 .expect("create client");
1114
1115 let original = json!({
1116 "id": "stable-subscription",
1117 "table": "todos",
1118 "scopes": { "list_id": ["list-2", "list-1"] },
1119 "params": "{\"view\":\"v1\"}"
1120 });
1121 dispatch(
1122 &mut transport,
1123 &mut client,
1124 &mut effects,
1125 "subscribe",
1126 &original,
1127 )
1128 .expect("register subscription");
1129 dispatch(
1130 &mut transport,
1131 &mut client,
1132 &mut effects,
1133 "subscribe",
1134 &json!({
1135 "id": "stable-subscription",
1136 "table": "todos",
1137 "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
1138 "params": "{\"view\":\"v1\"}"
1139 }),
1140 )
1141 .expect("canonical re-declaration is idempotent");
1142
1143 let error = dispatch(
1144 &mut transport,
1145 &mut client,
1146 &mut effects,
1147 "subscribe",
1148 &json!({
1149 "id": "stable-subscription",
1150 "table": "todos",
1151 "scopes": { "list_id": ["list-2"] },
1152 "params": "{\"view\":\"v1\"}"
1153 }),
1154 )
1155 .expect_err("changed native query identity must fail");
1156 assert_eq!(error.0, "client.subscription_intent_mismatch");
1157
1158 let state = dispatch(
1159 &mut transport,
1160 &mut client,
1161 &mut effects,
1162 "subscriptionState",
1163 &json!({ "id": "stable-subscription" }),
1164 )
1165 .expect("subscription state");
1166 assert_eq!(state["state"]["cursor"], -1);
1167 assert_eq!(state["state"]["table"], "todos");
1168 }
1169
1170 #[test]
1171 fn security_preflight_is_fail_closed_until_exact_activation() {
1172 let mut transport = NoNetwork::default();
1173 let mut client: Option<SyncClient> = None;
1174 let mut effects = CreateEffects::default();
1175 dispatch(
1176 &mut transport,
1177 &mut client,
1178 &mut effects,
1179 "create",
1180 &json!({ "schema": schema(), "securityPreflight": true }),
1181 )
1182 .expect("preflight create");
1183
1184 let lifecycle = dispatch(
1185 &mut transport,
1186 &mut client,
1187 &mut effects,
1188 "securityLifecycle",
1189 &json!({}),
1190 )
1191 .expect("lifecycle");
1192 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1193
1194 let query_error = dispatch(
1195 &mut transport,
1196 &mut client,
1197 &mut effects,
1198 "query",
1199 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1200 )
1201 .expect_err("protected query must fail");
1202 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1203 let diagnostics_error = dispatch(
1204 &mut transport,
1205 &mut client,
1206 &mut effects,
1207 "diagnosticsSnapshot",
1208 &json!({}),
1209 )
1210 .expect_err("diagnostics table/subscription evidence remains protected");
1211 assert_eq!(diagnostics_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1212
1213 dispatch(
1214 &mut transport,
1215 &mut client,
1216 &mut effects,
1217 "purgeLocalData",
1218 &json!({
1219 "input": {
1220 "purgeId": "directive-1",
1221 "targets": [{
1222 "table": "todos",
1223 "selectors": { "list_id": ["list-1"] }
1224 }]
1225 }
1226 }),
1227 )
1228 .expect("authorized local purge remains available");
1229
1230 let repair_error = dispatch(
1231 &mut transport,
1232 &mut client,
1233 &mut effects,
1234 "rebootstrapLocalData",
1235 &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1236 )
1237 .expect_err("projection repair must remain protected during preflight");
1238 assert_eq!(repair_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1239
1240 dispatch(
1241 &mut transport,
1242 &mut client,
1243 &mut effects,
1244 "activateSecurity",
1245 &json!({}),
1246 )
1247 .expect("activation");
1248 let rows = dispatch(
1249 &mut transport,
1250 &mut client,
1251 &mut effects,
1252 "query",
1253 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1254 )
1255 .expect("active query");
1256 assert_eq!(rows, json!({ "rows": [] }));
1257
1258 dispatch(
1259 &mut transport,
1260 &mut client,
1261 &mut effects,
1262 "beginSecurityPreflight",
1263 &json!({}),
1264 )
1265 .expect("re-enter preflight");
1266 let blocked = dispatch(
1267 &mut transport,
1268 &mut client,
1269 &mut effects,
1270 "mutate",
1271 &json!({ "mutations": [] }),
1272 )
1273 .expect_err("mutation must be gated");
1274 assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1275 }
1276
1277 #[test]
1278 fn preflight_refuses_a_replacement_create_without_the_flag() {
1279 let mut transport = NoNetwork::default();
1280 let mut client: Option<SyncClient> = None;
1281 let mut effects = CreateEffects::default();
1282 dispatch(
1283 &mut transport,
1284 &mut client,
1285 &mut effects,
1286 "create",
1287 &json!({ "schema": schema(), "securityPreflight": true }),
1288 )
1289 .expect("preflight create");
1290
1291 let escape = dispatch(
1293 &mut transport,
1294 &mut client,
1295 &mut effects,
1296 "create",
1297 &json!({ "schema": schema() }),
1298 )
1299 .expect_err("plain create must be refused during preflight");
1300 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1301 let query_error = dispatch(
1303 &mut transport,
1304 &mut client,
1305 &mut effects,
1306 "query",
1307 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1308 )
1309 .expect_err("protected query stays gated");
1310 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1311
1312 dispatch(
1314 &mut transport,
1315 &mut client,
1316 &mut effects,
1317 "create",
1318 &json!({ "schema": schema(), "securityPreflight": true }),
1319 )
1320 .expect("preflighted replacement create");
1321 let still_gated = dispatch(
1322 &mut transport,
1323 &mut client,
1324 &mut effects,
1325 "query",
1326 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1327 )
1328 .expect_err("replacement stays gated");
1329 assert_eq!(still_gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1330
1331 dispatch(
1333 &mut transport,
1334 &mut client,
1335 &mut effects,
1336 "activateSecurity",
1337 &json!({}),
1338 )
1339 .expect("activation");
1340 dispatch(
1341 &mut transport,
1342 &mut client,
1343 &mut effects,
1344 "create",
1345 &json!({ "schema": schema() }),
1346 )
1347 .expect("plain create after activation");
1348 let rows = dispatch(
1349 &mut transport,
1350 &mut client,
1351 &mut effects,
1352 "query",
1353 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1354 )
1355 .expect("active query");
1356 assert_eq!(rows, json!({ "rows": [] }));
1357 }
1358
1359 fn temp_db_path(tag: &str) -> String {
1362 std::env::temp_dir()
1363 .join(format!(
1364 "syncular-command-{tag}-{}-{:?}.db",
1365 std::process::id(),
1366 std::time::SystemTime::now()
1367 .duration_since(std::time::UNIX_EPOCH)
1368 .expect("clock")
1369 .as_nanos()
1370 ))
1371 .to_string_lossy()
1372 .into_owned()
1373 }
1374
1375 #[test]
1376 fn an_activated_replica_reopens_plainly_after_shutdown() {
1377 let path = temp_db_path("activated-reopen");
1383 let mut transport = NoNetwork::default();
1384 let mut client: Option<SyncClient> = None;
1385 let mut effects = CreateEffects::default();
1386
1387 dispatch(
1388 &mut transport,
1389 &mut client,
1390 &mut effects,
1391 "create",
1392 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1393 )
1394 .expect("preflight create");
1395 dispatch(
1396 &mut transport,
1397 &mut client,
1398 &mut effects,
1399 "activateSecurity",
1400 &json!({}),
1401 )
1402 .expect("activation clears the gate");
1403 dispatch(
1404 &mut transport,
1405 &mut client,
1406 &mut effects,
1407 "shutdown",
1408 &json!({}),
1409 )
1410 .expect("shutdown after activation");
1411
1412 dispatch(
1414 &mut transport,
1415 &mut client,
1416 &mut effects,
1417 "create",
1418 &json!({ "schema": schema(), "dbPath": path }),
1419 )
1420 .expect("an activated replica must reopen plainly after shutdown");
1421 let state = dispatch(
1422 &mut transport,
1423 &mut client,
1424 &mut effects,
1425 "securityLifecycle",
1426 &json!({}),
1427 )
1428 .expect("lifecycle");
1429 assert_eq!(state, json!({ "state": "active" }));
1430
1431 let _ = std::fs::remove_file(&path);
1432 }
1433
1434 #[test]
1435 fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
1436 let path = temp_db_path("quarantined-reopen");
1441 let mut transport = NoNetwork::default();
1442 let mut client: Option<SyncClient> = None;
1443 let mut effects = CreateEffects::default();
1444
1445 dispatch(
1446 &mut transport,
1447 &mut client,
1448 &mut effects,
1449 "create",
1450 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1451 )
1452 .expect("preflight create");
1453 dispatch(
1454 &mut transport,
1455 &mut client,
1456 &mut effects,
1457 "shutdown",
1458 &json!({}),
1459 )
1460 .expect("shutdown during preflight");
1461
1462 let mut rebuilt = CreateEffects::default();
1465 let escape = dispatch(
1466 &mut transport,
1467 &mut client,
1468 &mut rebuilt,
1469 "create",
1470 &json!({ "schema": schema(), "dbPath": path }),
1471 )
1472 .expect_err("a quarantined replica must refuse a plain re-create");
1473 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1474
1475 let _ = std::fs::remove_file(&path);
1476 }
1477
1478 #[test]
1479 fn preflight_gate_survives_shutdown_before_replacement_creates() {
1480 let mut transport = NoNetwork::default();
1481 let mut client: Option<SyncClient> = None;
1482 let mut effects = CreateEffects::default();
1483 dispatch(
1484 &mut transport,
1485 &mut client,
1486 &mut effects,
1487 "create",
1488 &json!({ "schema": schema(), "securityPreflight": true }),
1489 )
1490 .expect("preflight create");
1491 dispatch(
1492 &mut transport,
1493 &mut client,
1494 &mut effects,
1495 "shutdown",
1496 &json!({}),
1497 )
1498 .expect("shutdown during preflight");
1499 assert!(client.is_none());
1500
1501 let escape = dispatch(
1503 &mut transport,
1504 &mut client,
1505 &mut effects,
1506 "create",
1507 &json!({ "schema": schema() }),
1508 )
1509 .expect_err("shutdown + plain create must stay refused");
1510 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1511
1512 dispatch(
1513 &mut transport,
1514 &mut client,
1515 &mut effects,
1516 "create",
1517 &json!({ "schema": schema(), "securityPreflight": true }),
1518 )
1519 .expect("preflighted re-create");
1520 dispatch(
1521 &mut transport,
1522 &mut client,
1523 &mut effects,
1524 "activateSecurity",
1525 &json!({}),
1526 )
1527 .expect("activation");
1528
1529 dispatch(
1531 &mut transport,
1532 &mut client,
1533 &mut effects,
1534 "shutdown",
1535 &json!({}),
1536 )
1537 .expect("shutdown after activation");
1538 dispatch(
1539 &mut transport,
1540 &mut client,
1541 &mut effects,
1542 "create",
1543 &json!({ "schema": schema() }),
1544 )
1545 .expect("plain create after an activated shutdown");
1546 }
1547
1548 #[test]
1549 fn activate_security_validates_optional_headers_atomically() {
1550 let mut transport = NoNetwork::default();
1551 let mut client: Option<SyncClient> = None;
1552 let mut effects = CreateEffects::default();
1553 dispatch(
1554 &mut transport,
1555 &mut client,
1556 &mut effects,
1557 "create",
1558 &json!({ "schema": schema(), "securityPreflight": true }),
1559 )
1560 .expect("preflight create");
1561
1562 let invalid = dispatch(
1564 &mut transport,
1565 &mut client,
1566 &mut effects,
1567 "activateSecurity",
1568 &json!({ "headers": { "authorization": 7 } }),
1569 )
1570 .expect_err("non-string header must fail");
1571 assert_eq!(invalid.0, "sync.invalid_request");
1572 let lifecycle = dispatch(
1573 &mut transport,
1574 &mut client,
1575 &mut effects,
1576 "securityLifecycle",
1577 &json!({}),
1578 )
1579 .expect("lifecycle");
1580 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1581
1582 dispatch(
1584 &mut transport,
1585 &mut client,
1586 &mut effects,
1587 "activateSecurity",
1588 &json!({ "headers": { "authorization": "Bearer fresh" } }),
1589 )
1590 .expect("activation with fresh headers");
1591 let lifecycle = dispatch(
1592 &mut transport,
1593 &mut client,
1594 &mut effects,
1595 "securityLifecycle",
1596 &json!({}),
1597 )
1598 .expect("lifecycle");
1599 assert_eq!(lifecycle, json!({ "state": "active" }));
1600 }
1601
1602 #[test]
1603 fn parse_headers_reads_the_full_replacement_set() {
1604 let parsed = parse_headers(&json!({
1605 "authorization": "Bearer fresh",
1606 "x-tenant": "t1"
1607 }))
1608 .expect("valid headers parse");
1609 assert_eq!(
1610 parsed,
1611 vec![
1612 ("authorization".to_owned(), "Bearer fresh".to_owned()),
1613 ("x-tenant".to_owned(), "t1".to_owned())
1614 ]
1615 );
1616 assert!(parse_headers(&json!(["authorization"])).is_err());
1617 assert!(parse_headers(&json!({ "authorization": null })).is_err());
1618 }
1619}