1use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
37use std::sync::{Condvar, Mutex};
38use std::time::{Duration, Instant};
39
40use serde_json::{json, Value};
41use tauri::plugin::{Builder, TauriPlugin};
42use tauri::{Emitter, Manager, RunEvent, Runtime};
43
44pub mod core;
45pub mod transport;
46
47use core::SyncularCore;
48use syncular_client::{FileQuerySnapshotReader, QueryReadFailure, WindowBase, WindowCoverage};
49
50pub const EVENT_NAME: &str = "syncular://event";
52
53#[derive(Debug, Clone)]
58pub struct SyncularConfig {
59 pub base_url: Option<String>,
62 pub ws_url: Option<String>,
64 pub headers: Vec<(String, String)>,
66 pub db_path: Option<String>,
70 pub database_dir: Option<String>,
75 pub auto_sync: bool,
77}
78
79impl Default for SyncularConfig {
80 fn default() -> Self {
81 Self {
82 base_url: None,
83 ws_url: None,
84 headers: Vec::new(),
85 db_path: None,
86 database_dir: None,
87 auto_sync: true,
88 }
89 }
90}
91
92impl SyncularConfig {
93 fn to_transport_json(&self) -> Value {
95 let mut map = serde_json::Map::new();
96 if let Some(base) = &self.base_url {
97 map.insert("baseUrl".to_owned(), Value::from(base.clone()));
98 }
99 if let Some(ws) = &self.ws_url {
100 map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
101 }
102 if !self.headers.is_empty() {
103 let headers: serde_json::Map<String, Value> = self
104 .headers
105 .iter()
106 .map(|(k, v)| (k.clone(), Value::from(v.clone())))
107 .collect();
108 map.insert("headers".to_owned(), Value::Object(headers));
109 }
110 Value::Object(map)
111 }
112}
113
114enum Request {
117 Command {
118 command: Value,
119 reply: Sender<Value>,
120 },
121 Query {
122 sql: String,
123 params: Value,
124 reply: Sender<Value>,
125 },
126 SetHeaders {
130 headers: Vec<(String, String)>,
131 reply: Sender<Value>,
132 },
133 TransportWake,
135 QueryRead {
137 id: String,
138 tables: Vec<String>,
139 failure: Option<QueryReadFailure>,
140 },
141 #[cfg(test)]
142 Block {
143 duration: Duration,
144 entered: Sender<()>,
145 },
146 Shutdown,
147}
148
149enum ReadRequest {
153 QuerySnapshot {
154 sql: String,
155 params: Vec<Value>,
156 coverage: Vec<WindowCoverage>,
157 owner: Option<(String, Vec<String>)>,
158 reply: Sender<Value>,
159 },
160 Shutdown,
161}
162
163struct SyncularState {
166 sender: Mutex<Sender<Request>>,
167 reader: Mutex<Option<Sender<ReadRequest>>>,
172 config: SyncularConfig,
173 security_gate: SecurityGate,
174}
175
176struct SecurityGateState {
177 preflight: bool,
178 active_reads: usize,
179}
180
181struct SecurityGate {
182 state: Mutex<SecurityGateState>,
183 idle: Condvar,
184}
185
186struct SecurityReadGuard<'a> {
187 gate: &'a SecurityGate,
188}
189
190impl SecurityGate {
191 fn new_preflight() -> Self {
192 Self {
193 state: Mutex::new(SecurityGateState {
194 preflight: true,
195 active_reads: 0,
196 }),
197 idle: Condvar::new(),
198 }
199 }
200
201 fn begin_preflight(&self) -> Result<(), String> {
202 let mut state = self
203 .state
204 .lock()
205 .map_err(|_| "syncular security gate poisoned".to_owned())?;
206 state.preflight = true;
207 while state.active_reads > 0 {
208 state = self
209 .idle
210 .wait(state)
211 .map_err(|_| "syncular security gate poisoned".to_owned())?;
212 }
213 Ok(())
214 }
215
216 fn activate(&self) -> Result<(), String> {
217 let mut state = self
218 .state
219 .lock()
220 .map_err(|_| "syncular security gate poisoned".to_owned())?;
221 state.preflight = false;
222 Ok(())
223 }
224
225 fn enter_read(&self) -> Result<SecurityReadGuard<'_>, Value> {
226 let mut state = self
227 .state
228 .lock()
229 .map_err(|_| client_error("syncular security gate poisoned"))?;
230 if state.preflight {
231 return Err(security_preflight_error());
232 }
233 state.active_reads += 1;
234 Ok(SecurityReadGuard { gate: self })
235 }
236}
237
238impl Drop for SecurityReadGuard<'_> {
239 fn drop(&mut self) {
240 let Ok(mut state) = self.gate.state.lock() else {
241 return;
242 };
243 state.active_reads = state.active_reads.saturating_sub(1);
244 if state.active_reads == 0 {
245 self.gate.idle.notify_all();
246 }
247 }
248}
249
250impl SyncularState {
251 fn send(&self, request: Request) -> Result<(), String> {
252 self.sender
253 .lock()
254 .map_err(|_| "syncular mailbox poisoned".to_owned())?
255 .send(request)
256 .map_err(|_| "the syncular core thread has stopped".to_owned())
257 }
258
259 fn reader(&self) -> Result<Option<Sender<ReadRequest>>, String> {
260 Ok(self
261 .reader
262 .lock()
263 .map_err(|_| "syncular read mailbox poisoned".to_owned())?
264 .clone())
265 }
266}
267
268fn spawn_reader(path: String, owner_tx: Sender<Request>) -> Result<Sender<ReadRequest>, String> {
269 let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
270 std::thread::Builder::new()
271 .name("syncular-read".to_owned())
272 .spawn(move || run_reader_thread(path, reader_rx, owner_tx))
273 .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
274 Ok(reader_tx)
275}
276
277fn run_reader_thread(path: String, rx: Receiver<ReadRequest>, owner_tx: Sender<Request>) {
278 let mut reader = FileQuerySnapshotReader::new(path);
279 let mut failing = std::collections::HashSet::<String>::new();
282 while let Ok(request) = rx.recv() {
283 match request {
284 ReadRequest::QuerySnapshot {
285 sql,
286 params,
287 coverage,
288 owner,
289 reply,
290 } => {
291 let result = reader.query_snapshot(&sql, ¶ms, &coverage);
292 let value = match &result {
293 Ok(snapshot) => json!({ "result": snapshot }),
294 Err(failure) => json!({
295 "error": {
296 "code": failure.code.unwrap_or("client.failed"),
297 "message": failure.message,
298 "details": failure.details(),
299 }
300 }),
301 };
302 if let Some((id, tables)) = owner {
303 let failure = result.err();
304 let report = if failure.is_some() {
305 failing.insert(id.clone());
306 true
307 } else {
308 failing.remove(&id)
309 };
310 if report {
311 let _ = owner_tx.send(Request::QueryRead {
312 id,
313 tables,
314 failure,
315 });
316 }
317 }
318 let _ = reply.send(value);
319 }
320 ReadRequest::Shutdown => return,
321 }
322 }
323}
324
325fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
328where
329 F: Fn(&Value) + Send + Sync + 'static,
330{
331 let emit = std::sync::Arc::new(emit);
332 let transport_json = config.to_transport_json();
333 let wake_tx = tx.clone();
334 let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
335 let _ = wake_tx.send(Request::TransportWake);
336 });
337 let mut core = match SyncularCore::new_with_notify(&transport_json, Some(notify)) {
338 Ok(core) => core,
339 Err(message) => {
340 emit(&json!({ "type": "error", "message": message }));
343 return;
344 }
345 };
346 let progress_emit = emit.clone();
347 core.progress_listener = Some(std::sync::Arc::new(move |progress| {
348 progress_emit(&json!({ "type": "progress", "progress": progress }));
349 }));
350
351 let mut background_deadline: Option<Instant> = None;
354 loop {
355 if config.auto_sync {
356 match core.take_sync_intent() {
357 syncular_client::SyncIntent::Interactive => {
358 background_deadline = None;
359 core.sync_until_idle();
360 pump_events(&mut core, &*emit);
361 continue;
362 }
363 syncular_client::SyncIntent::Background { delay_ms } => {
364 let candidate = Instant::now()
365 .checked_add(Duration::from_millis(delay_ms))
366 .unwrap_or_else(Instant::now);
367 background_deadline = Some(
368 background_deadline.map_or(candidate, |current| current.min(candidate)),
369 );
370 }
371 syncular_client::SyncIntent::None => {}
372 }
373 }
374
375 let request = if let Some(deadline) = background_deadline {
376 let now = Instant::now();
377 if deadline <= now {
378 background_deadline = None;
379 core.sync_until_idle();
380 pump_events(&mut core, &*emit);
381 continue;
382 }
383 match rx.recv_timeout(deadline.saturating_duration_since(now)) {
384 Ok(request) => request,
385 Err(RecvTimeoutError::Timeout) => {
386 background_deadline = None;
387 core.sync_until_idle();
388 pump_events(&mut core, &*emit);
389 continue;
390 }
391 Err(RecvTimeoutError::Disconnected) => {
392 core.shutdown();
393 return;
394 }
395 }
396 } else {
397 match rx.recv() {
398 Ok(request) => request,
399 Err(std::sync::mpsc::RecvError) => {
400 core.shutdown();
401 return;
402 }
403 }
404 };
405
406 match request {
407 Request::Command { command, reply } => {
408 let result = core.command(&command);
409 if result.get("error").is_none() {
410 if let Some(headers) = activation_headers(&command) {
411 core.set_headers(headers);
417 }
418 }
419 let _ = reply.send(result);
420 pump_events(&mut core, &*emit);
421 }
422 Request::Query { sql, params, reply } => {
423 let result = core.query(&sql, params);
424 let _ = reply.send(result);
425 pump_events(&mut core, &*emit);
426 }
427 Request::SetHeaders { headers, reply } => {
428 core.set_headers(headers);
429 let _ = reply.send(json!({ "result": null }));
430 }
431 Request::TransportWake => {
432 core.poll_transport();
433 pump_events(&mut core, &*emit);
434 }
435 Request::QueryRead {
436 id,
437 tables,
438 failure,
439 } => {
440 core.record_query_read(&id, &tables, failure.as_ref());
441 pump_events(&mut core, &*emit);
442 }
443 #[cfg(test)]
444 Request::Block { duration, entered } => {
445 let _ = entered.send(());
446 std::thread::sleep(duration);
447 }
448 Request::Shutdown => {
449 core.shutdown();
450 return;
451 }
452 }
453 }
454}
455
456fn resolve_database(config: &SyncularConfig, params: &Value) -> Result<Option<String>, Value> {
463 if params.get("dbPath").is_some() {
464 return Err(invalid_request(
465 "sync.invalid_request: create.dbPath is host configuration; name a database with create.database",
466 ));
467 }
468 let Some(database) = params.get("database") else {
469 if config.db_path.is_none() && config.database_dir.is_some() {
470 return Err(invalid_request(
471 "sync.invalid_request: create.database is required when SyncularConfig sets database_dir without db_path",
472 ));
473 }
474 return Ok(config.db_path.clone());
475 };
476 let Some(name) = database.as_str().filter(|name| {
477 (1..=128).contains(&name.len())
478 && name.starts_with(|c: char| c.is_ascii_alphanumeric())
479 && !name.contains("..")
480 && name
481 .chars()
482 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
483 }) else {
484 return Err(invalid_request(
485 "sync.invalid_request: create.database must be 1 to 128 ASCII letters, digits, '-', '_' or '.', start with a letter or digit, and contain no '..'",
486 ));
487 };
488 let Some(dir) = &config.database_dir else {
489 return Err(invalid_request(
490 "sync.invalid_request: create.database needs SyncularConfig.database_dir",
491 ));
492 };
493 std::fs::create_dir_all(dir)
494 .map_err(|error| client_error(format!("cannot create the database directory: {error}")))?;
495 Ok(Some(
496 std::path::Path::new(dir)
497 .join(format!("{name}.db"))
498 .to_string_lossy()
499 .into_owned(),
500 ))
501}
502
503fn invalid_request(message: &str) -> Value {
504 json!({ "error": { "code": "sync.invalid_request", "message": message } })
505}
506
507fn activation_headers(command: &Value) -> Option<Vec<(String, String)>> {
511 if command.get("method").and_then(Value::as_str) != Some("activateSecurity") {
512 return None;
513 }
514 let headers = command.pointer("/params/headers")?;
515 syncular_command::parse_headers(headers).ok()
516}
517
518fn client_error(message: impl Into<String>) -> Value {
519 json!({ "error": { "code": "client.failed", "message": message.into() } })
520}
521
522fn security_preflight_error() -> Value {
523 json!({
524 "error": {
525 "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
526 "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
527 }
528 })
529}
530
531fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
532 let object = value
533 .and_then(Value::as_object)
534 .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
535 let table = object
536 .get("table")
537 .and_then(Value::as_str)
538 .ok_or_else(|| "window base missing table".to_owned())?
539 .to_owned();
540 let variable = object
541 .get("variable")
542 .and_then(Value::as_str)
543 .ok_or_else(|| "window base missing variable".to_owned())?
544 .to_owned();
545 let fixed_scopes = match object.get("fixedScopes") {
546 Some(value) => syncular_client::values::json_to_scope_map(value)?,
547 None => Vec::new(),
548 };
549 let params = object
550 .get("params")
551 .and_then(Value::as_str)
552 .map(str::to_owned);
553 Ok(WindowBase {
554 table,
555 variable,
556 fixed_scopes,
557 params,
558 })
559}
560
561fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
562 let Some(value) = value else {
563 return Ok(Vec::new());
564 };
565 if value.is_null() {
566 return Ok(Vec::new());
567 }
568 let entries = value
569 .as_array()
570 .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
571 entries
572 .iter()
573 .map(|entry| {
574 let units = entry
575 .get("units")
576 .and_then(Value::as_array)
577 .map(|values| {
578 values
579 .iter()
580 .filter_map(|value| value.as_str().map(str::to_owned))
581 .collect()
582 })
583 .unwrap_or_default();
584 Ok(WindowCoverage {
585 base: parse_window_base(entry.get("base"))?,
586 units,
587 })
588 })
589 .collect()
590}
591
592fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
593 for event in core.drain_events() {
594 emit(&event.json);
595 }
596}
597
598#[tauri::command]
601async fn syncular_command<R: Runtime>(
602 app: tauri::AppHandle<R>,
603 command: Value,
604) -> Result<Value, String> {
605 let state = app.state::<SyncularState>();
606 let method = command
607 .get("method")
608 .and_then(Value::as_str)
609 .unwrap_or("")
610 .to_owned();
611 if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
612 state.security_gate.begin_preflight()?;
615 }
616 let mut reader = if method == "create" || method == "shutdown" {
618 Some(
619 state
620 .reader
621 .lock()
622 .map_err(|_| "syncular read mailbox poisoned".to_owned())?,
623 )
624 } else {
625 None
626 };
627 let mut command = command;
628 let mut database = None;
629 if method == "create" {
630 database =
631 match resolve_database(&state.config, command.get("params").unwrap_or(&Value::Null)) {
632 Ok(path) => path,
633 Err(reply) => return Ok(reply),
634 };
635 if let Some(path) = &database {
636 match command.get_mut("params").and_then(Value::as_object_mut) {
637 Some(params) => {
638 params.insert("dbPath".to_owned(), Value::from(path.clone()));
639 }
640 None => {
641 if let Some(object) = command.as_object_mut() {
642 object.insert("params".to_owned(), json!({ "dbPath": path }));
643 }
644 }
645 }
646 }
647 }
648 let create_preflight = method == "create"
649 && command
650 .pointer("/params/securityPreflight")
651 .and_then(Value::as_bool)
652 .unwrap_or(false);
653 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
654 state.send(Request::Command {
655 command,
656 reply: reply_tx,
657 })?;
658 let reply = reply_rx
659 .recv()
660 .map_err(|_| "the syncular core dropped the reply".to_owned())?;
661 let succeeded = reply.get("error").is_none();
662 if let (true, Some(slot)) = (succeeded, reader.as_mut()) {
663 let next = match database.filter(|path| path.as_str() != ":memory:") {
664 Some(path) => Some(spawn_reader(
665 path,
666 state
667 .sender
668 .lock()
669 .map_err(|_| "syncular mailbox poisoned".to_owned())?
670 .clone(),
671 )?),
672 None => None,
673 };
674 if let Some(previous) = std::mem::replace(&mut **slot, next) {
675 let _ = previous.send(ReadRequest::Shutdown);
676 }
677 }
678 drop(reader);
679 if succeeded && method == "create" {
680 if !create_preflight {
681 state.security_gate.activate()?;
682 }
683 } else if succeeded && method == "activateSecurity" {
684 state.security_gate.activate()?;
685 }
686 Ok(reply)
687}
688
689#[tauri::command]
694async fn syncular_set_headers<R: Runtime>(
695 app: tauri::AppHandle<R>,
696 headers: std::collections::BTreeMap<String, String>,
697) -> Result<Value, String> {
698 let state = app.state::<SyncularState>();
699 let _active_guard = match state.security_gate.enter_read() {
703 Ok(guard) => guard,
704 Err(reply) => return Ok(reply),
705 };
706 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
707 state.send(Request::SetHeaders {
708 headers: headers.into_iter().collect(),
709 reply: reply_tx,
710 })?;
711 reply_rx
712 .recv()
713 .map_err(|_| "the syncular core dropped the reply".to_owned())
714}
715
716#[tauri::command]
717async fn syncular_query<R: Runtime>(
718 app: tauri::AppHandle<R>,
719 sql: String,
720 params: Option<Value>,
721) -> Result<Value, String> {
722 let state = app.state::<SyncularState>();
723 let _read_guard = match state.security_gate.enter_read() {
724 Ok(guard) => guard,
725 Err(reply) => return Ok(reply),
726 };
727 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
728 state.send(Request::Query {
729 sql,
730 params: params.unwrap_or(Value::Null),
731 reply: reply_tx,
732 })?;
733 reply_rx
734 .recv()
735 .map_err(|_| "the syncular core dropped the reply".to_owned())
736}
737
738#[tauri::command]
742async fn syncular_query_snapshot<R: Runtime>(
743 app: tauri::AppHandle<R>,
744 sql: String,
745 params: Option<Value>,
746 coverage: Option<Value>,
747 owner: Option<Value>,
748) -> Result<Value, String> {
749 let state = app.state::<SyncularState>();
750 let _read_guard = match state.security_gate.enter_read() {
751 Ok(guard) => guard,
752 Err(reply) => return Ok(reply),
753 };
754 let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
755 let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
756
757 if let Some(reader) = state.reader()? {
758 let bind = match params_value.as_array() {
759 Some(values) => values.clone(),
760 None => return Ok(client_error("querySnapshot params must be a list")),
761 };
762 let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
763 Ok(value) => value,
764 Err(message) => return Ok(client_error(message)),
765 };
766 let parsed_owner = match &owner {
768 None | Some(Value::Null) => None,
769 Some(value) => {
770 let id = value
771 .get("id")
772 .and_then(Value::as_str)
773 .filter(|id| !id.is_empty());
774 let tables = value
775 .get("tables")
776 .and_then(Value::as_array)
777 .and_then(|list| {
778 list.iter()
779 .map(|table| table.as_str().map(str::to_owned))
780 .collect::<Option<Vec<String>>>()
781 });
782 match (id, tables) {
783 (Some(id), Some(tables)) => Some((id.to_owned(), tables)),
784 _ => {
785 return Ok(json!({ "error": {
786 "code": "sync.invalid_request",
787 "message": "sync.invalid_request: querySnapshot owner must be {id: non-empty string, tables: string[]}",
788 } }))
789 }
790 }
791 }
792 };
793 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
794 reader
795 .send(ReadRequest::QuerySnapshot {
796 sql,
797 params: bind,
798 coverage: parsed_coverage,
799 owner: parsed_owner,
800 reply: reply_tx,
801 })
802 .map_err(|_| "the syncular read thread has stopped".to_owned())?;
803 return reply_rx
804 .recv()
805 .map_err(|_| "the syncular read thread dropped the reply".to_owned());
806 }
807
808 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
809 state.send(Request::Command {
810 command: json!({
811 "method": "querySnapshot",
812 "params": {
813 "sql": sql,
814 "params": params_value,
815 "coverage": coverage_value,
816 "owner": owner.unwrap_or(Value::Null),
817 }
818 }),
819 reply: reply_tx,
820 })?;
821 reply_rx
822 .recv()
823 .map_err(|_| "the syncular core dropped the reply".to_owned())
824}
825
826pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
834 Builder::<R>::new("syncular")
835 .invoke_handler(tauri::generate_handler![
836 syncular_command,
837 syncular_query,
838 syncular_query_snapshot,
839 syncular_set_headers
840 ])
841 .setup(move |app, _api| {
842 let (tx, rx) = std::sync::mpsc::channel::<Request>();
843 app.manage(SyncularState {
844 sender: Mutex::new(tx.clone()),
845 reader: Mutex::new(None),
846 config: config.clone(),
847 security_gate: SecurityGate::new_preflight(),
850 });
851 let app_handle = app.clone();
852 let emit = move |value: &Value| {
853 let _ = app_handle.emit(EVENT_NAME, value.clone());
856 };
857 std::thread::Builder::new()
858 .name("syncular-core".to_owned())
859 .spawn(move || run_owner_thread(config, tx, rx, emit))
860 .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
861 Ok(())
862 })
863 .on_event(|app, event| {
864 if let RunEvent::Exit = event {
865 if let Some(state) = app.try_state::<SyncularState>() {
866 let _ = state.send(Request::Shutdown);
867 if let Ok(Some(reader)) = state.reader() {
868 let _ = reader.send(ReadRequest::Shutdown);
869 }
870 }
871 }
872 })
873 .build()
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879
880 #[test]
881 fn config_to_transport_json_shapes_fields() {
882 let config = SyncularConfig {
883 base_url: Some("https://api.example.com".to_owned()),
884 headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
885 ..Default::default()
886 };
887 let json = config.to_transport_json();
888 assert_eq!(json["baseUrl"], "https://api.example.com");
889 assert_eq!(json["headers"]["authorization"], "Bearer x");
890 }
891
892 #[test]
893 fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
894 let gate = std::sync::Arc::new(SecurityGate::new_preflight());
895 gate.activate().expect("activate test gate");
896 let read = gate.enter_read().expect("active read");
897 let gate_for_barrier = std::sync::Arc::clone(&gate);
898 let (done_tx, done_rx) = std::sync::mpsc::channel();
899 let barrier = std::thread::spawn(move || {
900 gate_for_barrier.begin_preflight().expect("enter preflight");
901 done_tx.send(()).expect("barrier reply");
902 });
903
904 assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
905 drop(read);
906 done_rx
907 .recv_timeout(Duration::from_secs(1))
908 .expect("barrier drains after the read");
909 barrier.join().expect("barrier thread");
910 assert!(gate.enter_read().is_err());
911 }
912
913 #[test]
914 fn direct_set_headers_command_respects_the_native_preflight_gate() {
915 use std::collections::BTreeMap;
916 use tauri::test::{mock_builder, mock_context, noop_assets};
917
918 let app = mock_builder()
919 .plugin(init(SyncularConfig {
920 auto_sync: false,
921 ..Default::default()
922 }))
923 .build(mock_context(noop_assets()))
924 .expect("build mock app");
925
926 let blocked = tauri::async_runtime::block_on(syncular_set_headers(
927 app.handle().clone(),
928 BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
929 ))
930 .expect("preflight reply");
931 assert_eq!(
932 blocked["error"]["code"],
933 Value::from("client.security_preflight_required")
934 );
935
936 let created = tauri::async_runtime::block_on(syncular_command(
937 app.handle().clone(),
938 json!({
939 "method": "create",
940 "params": {
941 "clientId": "native-header-gate",
942 "schema": { "version": 1, "tables": [] }
943 }
944 }),
945 ))
946 .expect("create reply");
947 assert!(created.get("error").is_none(), "{created}");
948
949 let active = tauri::async_runtime::block_on(syncular_set_headers(
950 app.handle().clone(),
951 BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
952 ))
953 .expect("active reply");
954 assert_eq!(active["result"], Value::Null);
955 }
956
957 #[test]
958 fn activation_headers_extracts_only_the_activation_set() {
959 assert_eq!(
960 activation_headers(&json!({
961 "method": "activateSecurity",
962 "params": { "headers": { "authorization": "Bearer fresh" } }
963 })),
964 Some(vec![(
965 "authorization".to_owned(),
966 "Bearer fresh".to_owned()
967 )])
968 );
969 assert_eq!(
971 activation_headers(&json!({ "method": "activateSecurity", "params": {} })),
972 None
973 );
974 assert_eq!(
975 activation_headers(&json!({
976 "method": "setWindow",
977 "params": { "headers": { "authorization": "Bearer fresh" } }
978 })),
979 None
980 );
981 assert_eq!(
982 activation_headers(&json!({
983 "method": "activateSecurity",
984 "params": { "headers": { "authorization": 7 } }
985 })),
986 None
987 );
988 }
989
990 #[test]
991 fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
992 use tauri::test::{mock_builder, mock_context, noop_assets};
993
994 let app = mock_builder()
995 .plugin(init(SyncularConfig {
996 auto_sync: false,
997 ..Default::default()
998 }))
999 .build(mock_context(noop_assets()))
1000 .expect("build mock app");
1001 let command = |value: Value| {
1002 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1003 .expect("command reply")
1004 };
1005
1006 let created = command(json!({
1007 "method": "create",
1008 "params": {
1009 "clientId": "native-preflight-escape",
1010 "schema": { "version": 1, "tables": [] },
1011 "securityPreflight": true
1012 }
1013 }));
1014 assert!(created.get("error").is_none(), "{created}");
1015
1016 let escape = command(json!({
1019 "method": "create",
1020 "params": {
1021 "clientId": "native-preflight-escape",
1022 "schema": { "version": 1, "tables": [] }
1023 }
1024 }));
1025 assert_eq!(
1026 escape["error"]["code"],
1027 Value::from("client.security_preflight_required"),
1028 "{escape}"
1029 );
1030 let read = tauri::async_runtime::block_on(syncular_query(
1031 app.handle().clone(),
1032 "SELECT 1".to_owned(),
1033 None,
1034 ))
1035 .expect("query reply");
1036 assert_eq!(
1037 read["error"]["code"],
1038 Value::from("client.security_preflight_required")
1039 );
1040
1041 let activated = command(json!({
1043 "method": "activateSecurity",
1044 "params": { "headers": { "authorization": "Bearer fresh" } }
1045 }));
1046 assert!(activated.get("error").is_none(), "{activated}");
1047 let read = tauri::async_runtime::block_on(syncular_query(
1048 app.handle().clone(),
1049 "SELECT 1 AS value".to_owned(),
1050 None,
1051 ))
1052 .expect("query reply");
1053 assert_eq!(read["result"]["rows"][0]["value"], 1);
1054 let recreated = command(json!({
1055 "method": "create",
1056 "params": {
1057 "clientId": "native-preflight-escape",
1058 "schema": { "version": 1, "tables": [] }
1059 }
1060 }));
1061 assert!(recreated.get("error").is_none(), "{recreated}");
1062 }
1063
1064 #[test]
1065 fn resolve_database_names_a_file_inside_the_configured_directory() {
1066 let dir =
1067 std::env::temp_dir().join(format!("syncular-tauri-resolve-{}", std::process::id()));
1068 let config = SyncularConfig {
1069 db_path: Some("/tmp/default.db".to_owned()),
1070 database_dir: Some(dir.to_string_lossy().into_owned()),
1071 ..Default::default()
1072 };
1073 assert_eq!(
1075 resolve_database(&config, &json!({ "clientId": "c1" })),
1076 Ok(Some("/tmp/default.db".to_owned()))
1077 );
1078 assert_eq!(
1080 resolve_database(&config, &json!({ "database": "app-actor-0f.v2" })),
1081 Ok(Some(
1082 dir.join("app-actor-0f.v2.db")
1083 .to_string_lossy()
1084 .into_owned()
1085 ))
1086 );
1087 assert!(dir.is_dir());
1088 for params in [
1091 json!({ "dbPath": "/etc/other.db" }),
1092 json!({ "database": "../escape" }),
1093 json!({ "database": "a/b" }),
1094 json!({ "database": "a\\b" }),
1095 json!({ "database": "/abs" }),
1096 json!({ "database": ".hidden" }),
1097 json!({ "database": "a..b" }),
1098 json!({ "database": "" }),
1099 json!({ "database": "x".repeat(129) }),
1100 json!({ "database": 7 }),
1101 ] {
1102 let refused = resolve_database(&config, ¶ms).expect_err("refused");
1103 assert_eq!(refused["error"]["code"], "sync.invalid_request", "{params}");
1104 }
1105 let refused = resolve_database(
1108 &SyncularConfig {
1109 database_dir: Some(dir.to_string_lossy().into_owned()),
1110 ..Default::default()
1111 },
1112 &json!({ "clientId": "c1" }),
1113 )
1114 .expect_err("name required");
1115 assert_eq!(refused["error"]["code"], "sync.invalid_request");
1116 let refused = resolve_database(&SyncularConfig::default(), &json!({ "database": "actor" }))
1119 .expect_err("no directory");
1120 assert_eq!(refused["error"]["code"], "sync.invalid_request");
1121 std::fs::remove_dir_all(dir).expect("remove temp directory");
1122 }
1123
1124 #[test]
1127 fn named_databases_keep_one_replica_and_client_id_per_actor() {
1128 use tauri::test::{mock_builder, mock_context, noop_assets};
1129
1130 let dir =
1131 std::env::temp_dir().join(format!("syncular-tauri-actors-{}", std::process::id()));
1132 let _ = std::fs::remove_dir_all(&dir);
1133 let app = mock_builder()
1134 .plugin(init(SyncularConfig {
1135 database_dir: Some(dir.to_string_lossy().into_owned()),
1136 auto_sync: false,
1137 ..Default::default()
1138 }))
1139 .build(mock_context(noop_assets()))
1140 .expect("build mock app");
1141 let command = |value: Value| {
1142 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1143 .expect("command reply")
1144 };
1145 let snapshot = |sql: &str| {
1146 tauri::async_runtime::block_on(syncular_query_snapshot(
1147 app.handle().clone(),
1148 sql.to_owned(),
1149 None,
1150 None,
1151 None,
1152 ))
1153 .expect("snapshot reply")
1154 };
1155 let schema = json!({
1156 "version": 1,
1157 "tables": [{
1158 "name": "todo", "primaryKey": "id",
1159 "columns": [
1160 { "name": "id", "type": "string", "nullable": false },
1161 { "name": "title", "type": "string", "nullable": false }
1162 ],
1163 "scopes": []
1164 }]
1165 });
1166 let create = |database: &str, client_id: &str| {
1167 command(json!({ "method": "create", "params": {
1168 "database": database, "clientId": client_id, "schema": schema
1169 } }))
1170 };
1171
1172 assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1173 command(json!({ "method": "mutate", "params": { "mutations": [{
1174 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "a" }
1175 }] } }));
1176 assert_eq!(
1177 snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1178 "a"
1179 );
1180 assert_eq!(
1181 command(json!({ "method": "shutdown" }))["result"],
1182 json!({})
1183 );
1184
1185 assert_eq!(create("actor-b", "client-b")["result"], json!({}));
1186 assert_eq!(
1187 snapshot("SELECT title FROM todo")["result"]["rows"],
1188 json!([]),
1189 "the reader follows the second actor's database"
1190 );
1191 assert!(dir.join("actor-a.db").is_file());
1192 assert!(dir.join("actor-b.db").is_file());
1193 assert_eq!(
1194 command(json!({ "method": "shutdown" }))["result"],
1195 json!({})
1196 );
1197
1198 assert_eq!(
1200 create("actor-a", "client-b")["error"]["code"],
1201 "client.identity_mismatch"
1202 );
1203 assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1204 assert_eq!(
1205 snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1206 "a"
1207 );
1208 assert_eq!(
1209 create("../actor-a", "client-a")["error"]["code"],
1210 "sync.invalid_request"
1211 );
1212 command(json!({ "method": "shutdown" }));
1213 std::fs::remove_dir_all(dir).expect("remove temp directory");
1214 }
1215
1216 #[test]
1217 fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
1218 let parsed = parse_coverage(Some(&json!([{
1219 "base": {
1220 "table": "tasks",
1221 "variable": "project_id",
1222 "fixedScopes": { "tenant_id": ["one", "two"] },
1223 "params": "opaque"
1224 },
1225 "units": ["a", "b"]
1226 }])))
1227 .expect("parse coverage");
1228 assert_eq!(parsed.len(), 1);
1229 let entry = &parsed[0];
1230 assert_eq!(entry.base.table, "tasks");
1231 assert_eq!(entry.base.variable, "project_id");
1232 assert_eq!(
1233 entry.base.fixed_scopes,
1234 vec![(
1235 "tenant_id".to_owned(),
1236 vec!["one".to_owned(), "two".to_owned()]
1237 )]
1238 );
1239 assert_eq!(entry.base.params.as_deref(), Some("opaque"));
1240 assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
1241 }
1242
1243 #[test]
1247 fn owner_thread_round_trips_over_mailbox() {
1248 use std::sync::mpsc::channel;
1249 use std::sync::{Arc, Mutex as StdMutex};
1250
1251 let (tx, rx) = channel::<Request>();
1252 let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
1253 let events_for_thread = Arc::clone(&events);
1254 let config = SyncularConfig {
1255 auto_sync: false,
1256 ..Default::default()
1257 };
1258 let owner_tx = tx.clone();
1259 let handle = std::thread::spawn(move || {
1260 run_owner_thread(config, owner_tx, rx, move |v| {
1261 events_for_thread.lock().unwrap().push(v.clone());
1262 });
1263 });
1264
1265 let call = |command: Value| -> Value {
1266 let (rtx, rrx) = channel();
1267 tx.send(Request::Command {
1268 command,
1269 reply: rtx,
1270 })
1271 .unwrap();
1272 rrx.recv().unwrap()
1273 };
1274
1275 let schema = json!({
1276 "version": 1,
1277 "tables": [{
1278 "name": "todo", "primaryKey": "id",
1279 "columns": [
1280 { "name": "id", "type": "string", "nullable": false },
1281 { "name": "title", "type": "string", "nullable": false }
1282 ],
1283 "scopes": []
1284 }]
1285 });
1286 assert_eq!(
1287 call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1288 ["result"],
1289 json!({})
1290 );
1291 call(json!({ "method": "mutate", "params": { "mutations": [{
1292 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1293 }] } }));
1294
1295 let (qtx, qrx) = channel();
1297 tx.send(Request::Query {
1298 sql: "SELECT title FROM todo".to_owned(),
1299 params: Value::Null,
1300 reply: qtx,
1301 })
1302 .unwrap();
1303 let rows = qrx.recv().unwrap();
1304 assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1305
1306 let (htx, hrx) = channel();
1309 tx.send(Request::SetHeaders {
1310 headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1311 reply: htx,
1312 })
1313 .unwrap();
1314 assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1315
1316 tx.send(Request::Shutdown).unwrap();
1317 handle.join().unwrap();
1318
1319 let seen = events.lock().unwrap();
1320 let kinds: Vec<String> = seen
1321 .iter()
1322 .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1323 .collect();
1324 assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1326 }
1327
1328 #[test]
1329 fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
1330 use std::sync::mpsc::channel;
1331
1332 let path =
1333 std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
1334 let config = SyncularConfig {
1335 db_path: Some(path.to_string_lossy().into_owned()),
1336 auto_sync: false,
1337 ..Default::default()
1338 };
1339 let (tx, rx) = channel::<Request>();
1340 let owner_tx = tx.clone();
1341 let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
1342
1343 let (create_tx, create_rx) = channel();
1344 tx.send(Request::Command {
1345 command: json!({
1346 "method": "create",
1347 "params": {
1348 "clientId": "sidecar-client",
1349 "schema": { "version": 1, "tables": [] },
1350 "dbPath": path.to_string_lossy()
1351 }
1352 }),
1353 reply: create_tx,
1354 })
1355 .expect("post create");
1356 assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
1357
1358 let (read_tx, read_rx) = channel::<ReadRequest>();
1359 let read_path = path.to_string_lossy().into_owned();
1360 let owner_tx = tx.clone();
1361 let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
1362
1363 let (entered_tx, entered_rx) = channel();
1366 tx.send(Request::Block {
1367 duration: Duration::from_millis(200),
1368 entered: entered_tx,
1369 })
1370 .expect("block owner");
1371 entered_rx.recv().expect("owner entered blocking round");
1372 let (snapshot_tx, snapshot_rx) = channel();
1373 read_tx
1374 .send(ReadRequest::QuerySnapshot {
1375 sql: "SELECT 1 AS value".to_owned(),
1376 params: Vec::new(),
1377 coverage: Vec::new(),
1378 owner: None,
1379 reply: snapshot_tx,
1380 })
1381 .expect("post snapshot");
1382 let snapshot = snapshot_rx
1383 .recv_timeout(Duration::from_millis(50))
1384 .expect("local snapshot must not wait for the owner");
1385 assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
1386
1387 let (failed_tx, failed_rx) = channel();
1388 read_tx
1389 .send(ReadRequest::QuerySnapshot {
1390 sql: "SELECT private_value FROM missing_private_table".to_owned(),
1391 params: Vec::new(),
1392 coverage: Vec::new(),
1393 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1394 reply: failed_tx,
1395 })
1396 .expect("post failed snapshot");
1397 assert_eq!(
1398 failed_rx.recv().expect("failed snapshot reply")["error"]["code"],
1399 "client.failed"
1400 );
1401 let (diagnostics_tx, diagnostics_rx) = channel();
1402 tx.send(Request::Command {
1403 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1404 reply: diagnostics_tx,
1405 })
1406 .expect("post diagnostics");
1407 assert_eq!(
1408 diagnostics_rx.recv().expect("diagnostics reply")["result"]["queryFailures"][0]["id"],
1409 "queries:missing"
1410 );
1411
1412 let (recovered_tx, recovered_rx) = channel();
1413 read_tx
1414 .send(ReadRequest::QuerySnapshot {
1415 sql: "SELECT 1 AS value".to_owned(),
1416 params: Vec::new(),
1417 coverage: Vec::new(),
1418 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1419 reply: recovered_tx,
1420 })
1421 .expect("post recovered snapshot");
1422 assert!(recovered_rx.recv().expect("recovered snapshot reply")["result"].is_object());
1423 let (cleared_tx, cleared_rx) = channel();
1424 tx.send(Request::Command {
1425 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1426 reply: cleared_tx,
1427 })
1428 .expect("post cleared diagnostics");
1429 assert_eq!(
1430 cleared_rx.recv().expect("cleared diagnostics reply")["result"]["queryFailures"],
1431 json!([])
1432 );
1433
1434 read_tx.send(ReadRequest::Shutdown).expect("stop reader");
1435 reader.join().expect("join reader");
1436 tx.send(Request::Shutdown).expect("stop owner");
1437 owner.join().expect("join owner");
1438 std::fs::remove_file(path).expect("remove temp database");
1439 }
1440}