1use crate::client::{SubscriptionSpec, SyncularClientConfig};
2use crate::encryption::FieldEncryptionRule;
3#[cfg(feature = "native")]
4use crate::error::ErrorKind;
5use crate::error::{Result, SyncularError};
6use crate::protocol::{
7 AuthLeaseIssueRequest, AuthLeaseIssueResponse, AuthLeasePayload, AuthLeaseScope,
8 AUTH_LEASE_PROTOCOL_VERSION, AUTH_LEASE_VERSION,
9};
10#[cfg(feature = "native")]
11use crate::protocol::{ScopeValues, SyncChange};
12#[cfg(feature = "native")]
13use diesel::sqlite::SqliteConnection;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use sha2::{Digest, Sha256};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub enum ScopeSource {
21 ActorId,
22 ProjectId,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26pub struct ScopeMetadata {
27 pub name: &'static str,
28 pub column: &'static str,
29 pub source: ScopeSource,
30 pub required: bool,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
34pub struct ColumnMetadata {
35 pub name: &'static str,
36 pub type_family: &'static str,
37 pub notnull_required: bool,
38 pub primary_key: bool,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
42pub struct CrdtYjsFieldMetadata {
43 pub field: &'static str,
44 pub state_column: &'static str,
45 pub container_key: &'static str,
46 pub row_id_field: &'static str,
47 pub kind: &'static str,
48 pub sync_mode: &'static str,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
52pub struct EncryptedFieldMetadata {
53 pub field: &'static str,
54 pub scope: &'static str,
55 pub row_id_field: &'static str,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
59pub struct AppTableMetadata {
60 pub name: &'static str,
61 pub primary_key_column: &'static str,
62 pub server_version_column: &'static str,
63 pub soft_delete_column: Option<&'static str>,
64 pub subscription_id: &'static str,
65 pub columns: &'static [ColumnMetadata],
66 pub blob_columns: &'static [&'static str],
67 pub crdt_yjs_fields: &'static [CrdtYjsFieldMetadata],
68 pub encrypted_fields: &'static [EncryptedFieldMetadata],
69 pub scopes: &'static [ScopeMetadata],
70}
71
72#[derive(Debug, Clone, Copy)]
73pub struct EmbeddedMigration {
74 pub version: &'static str,
75 pub schema_version: i32,
76 pub name: &'static str,
77 pub up_sql: &'static str,
78}
79
80#[cfg(feature = "native")]
81pub trait DieselTableAdapter: Sync {
82 fn name(&self) -> &'static str;
83 fn list_rows_json(&self, conn: &mut SqliteConnection) -> Result<Vec<Value>>;
84 fn clear_for_scopes(&self, conn: &mut SqliteConnection, scopes: &ScopeValues) -> Result<()>;
85 fn upsert_row(
86 &self,
87 conn: &mut SqliteConnection,
88 row: &Value,
89 fallback_version: Option<i64>,
90 ) -> Result<()>;
91 fn upsert_rows(
92 &self,
93 conn: &mut SqliteConnection,
94 rows: &[Value],
95 fallback_version: Option<i64>,
96 ) -> Result<()> {
97 for row in rows {
98 self.upsert_row(conn, row, fallback_version)?;
99 }
100 Ok(())
101 }
102 fn apply_change(&self, conn: &mut SqliteConnection, change: &SyncChange) -> Result<()>;
103}
104
105#[derive(Clone, Copy)]
106pub struct AppSchema {
107 pub app_tables: &'static [&'static str],
108 pub app_table_metadata: &'static [AppTableMetadata],
109 pub migrations: &'static [EmbeddedMigration],
110 pub schema_version: Option<i32>,
111 pub default_subscriptions: fn(&SyncularClientConfig) -> Vec<SubscriptionSpec>,
112 #[cfg(feature = "native")]
113 pub adapter_for: fn(&str) -> Result<&'static dyn DieselTableAdapter>,
114}
115
116impl AppSchema {
117 pub fn current_schema_version(&self) -> i32 {
118 self.schema_version
119 .unwrap_or_else(|| current_schema_version(self.migrations))
120 }
121
122 pub fn table_metadata(&self, table: &str) -> Option<&'static AppTableMetadata> {
123 self.app_table_metadata
124 .iter()
125 .find(|metadata| metadata.name == table)
126 }
127
128 pub fn default_subscriptions(&self, config: &SyncularClientConfig) -> Vec<SubscriptionSpec> {
129 (self.default_subscriptions)(config)
130 }
131
132 #[cfg(feature = "native")]
133 pub fn adapter_for(&self, table: &str) -> Result<&'static dyn DieselTableAdapter> {
134 (self.adapter_for)(table)
135 }
136}
137
138pub fn validate_app_schema_runtime_features(app_schema: &AppSchema) -> Result<()> {
139 for table in app_schema.app_table_metadata {
140 if !cfg!(any(feature = "native", feature = "web-blobs")) && !table.blob_columns.is_empty() {
141 return Err(SyncularError::config(format!(
142 "app schema table {} requires blobs runtime feature",
143 table.name
144 )));
145 }
146
147 if !cfg!(feature = "crdt-yjs") && !table.crdt_yjs_fields.is_empty() {
148 return Err(SyncularError::config(format!(
149 "app schema table {} requires crdt-yjs runtime feature",
150 table.name
151 )));
152 }
153
154 if !cfg!(feature = "e2ee")
155 && (!table.encrypted_fields.is_empty()
156 || table
157 .crdt_yjs_fields
158 .iter()
159 .any(|field| field.sync_mode == "encrypted-update-log"))
160 {
161 return Err(SyncularError::config(format!(
162 "app schema table {} requires e2ee runtime feature",
163 table.name
164 )));
165 }
166 }
167
168 Ok(())
169}
170
171pub fn validate_field_encryption_rules_against_app_schema(
172 app_schema: AppSchema,
173 rules: &[FieldEncryptionRule],
174) -> Result<()> {
175 for rule in rules {
176 let table_name = rule.table.as_deref().ok_or_else(|| {
177 SyncularError::config(format!(
178 "field encryption rule for scope {} must specify a generated table",
179 rule.scope
180 ))
181 })?;
182 let metadata = app_schema.table_metadata(table_name).ok_or_else(|| {
183 SyncularError::config(format!(
184 "field encryption rule references unknown generated table {table_name}"
185 ))
186 })?;
187 let row_id_field = rule
188 .row_id_field
189 .as_deref()
190 .unwrap_or(metadata.primary_key_column);
191 if !metadata
192 .columns
193 .iter()
194 .any(|column| column.name == row_id_field)
195 {
196 return Err(SyncularError::config(format!(
197 "field encryption rule for {}.{} references unknown rowIdField {}",
198 rule.scope, table_name, row_id_field
199 )));
200 }
201
202 for field in &rule.fields {
203 let declared = metadata.encrypted_fields.iter().any(|candidate| {
204 candidate.field == field
205 && candidate.scope == rule.scope
206 && candidate.row_id_field == row_id_field
207 });
208 if !declared {
209 return Err(SyncularError::config(format!(
210 "field encryption rule for {}.{} is not declared in the generated app schema",
211 table_name, field
212 )));
213 }
214 }
215 }
216
217 Ok(())
218}
219
220pub fn validate_blob_encryption_against_app_schema(app_schema: AppSchema) -> Result<()> {
221 if validate_blob_runtime_against_app_schema(app_schema).is_ok() {
222 return Ok(());
223 }
224
225 Err(SyncularError::config(
226 "blob encryption requires at least one generated blob column",
227 ))
228}
229
230pub fn validate_blob_runtime_against_app_schema(app_schema: AppSchema) -> Result<()> {
231 if app_schema
232 .app_table_metadata
233 .iter()
234 .any(|table| !table.blob_columns.is_empty())
235 {
236 return Ok(());
237 }
238
239 Err(SyncularError::config(
240 "blob operations require at least one generated blob column",
241 ))
242}
243
244pub fn validate_encrypted_crdt_against_app_schema(app_schema: AppSchema) -> Result<()> {
245 if app_schema.app_table_metadata.iter().any(|table| {
246 table
247 .crdt_yjs_fields
248 .iter()
249 .any(|field| field.sync_mode == "encrypted-update-log")
250 }) {
251 return Ok(());
252 }
253
254 Err(SyncularError::config(
255 "encrypted CRDT config requires at least one generated encrypted-update-log CRDT field",
256 ))
257}
258
259pub fn validate_auth_lease_issue_request_against_app_schema(
260 app_schema: AppSchema,
261 request: &AuthLeaseIssueRequest,
262) -> Result<()> {
263 validate_auth_lease_schema_version(app_schema, request.schema_version, "auth lease request")?;
264 if let Some(ttl_ms) = request.ttl_ms {
265 if ttl_ms <= 0 {
266 return Err(SyncularError::config(
267 "auth lease request ttlMs must be positive",
268 ));
269 }
270 }
271 validate_auth_lease_scopes_against_app_schema(app_schema, &request.scopes, "auth lease request")
272}
273
274pub fn validate_auth_lease_issue_response_against_app_schema(
275 app_schema: AppSchema,
276 response: &AuthLeaseIssueResponse,
277 request_schema_version: i32,
278) -> Result<()> {
279 if response.payload.schema_version != request_schema_version {
280 return Err(SyncularError::protocol_message(format!(
281 "auth lease response schemaVersion {} does not match request schemaVersion {}",
282 response.payload.schema_version, request_schema_version
283 )));
284 }
285 validate_auth_lease_payload_against_app_schema(app_schema, &response.payload)
286}
287
288pub fn validate_auth_lease_payload_against_app_schema(
289 app_schema: AppSchema,
290 payload: &AuthLeasePayload,
291) -> Result<()> {
292 if payload.version != AUTH_LEASE_VERSION {
293 return Err(SyncularError::protocol_message(
294 "auth lease payload version is unsupported",
295 ));
296 }
297 if payload.protocol_version != AUTH_LEASE_PROTOCOL_VERSION {
298 return Err(SyncularError::protocol_message(
299 "auth lease payload protocolVersion is unsupported",
300 ));
301 }
302 validate_auth_lease_schema_version(app_schema, payload.schema_version, "auth lease payload")?;
303 validate_auth_lease_scopes_against_app_schema(app_schema, &payload.scopes, "auth lease payload")
304}
305
306fn validate_auth_lease_schema_version(
307 app_schema: AppSchema,
308 schema_version: i32,
309 source: &str,
310) -> Result<()> {
311 let current = app_schema.current_schema_version();
312 if schema_version == current {
313 return Ok(());
314 }
315 Err(SyncularError::config(format!(
316 "{source} schemaVersion {schema_version} does not match generated app schema version {current}"
317 )))
318}
319
320fn validate_auth_lease_scopes_against_app_schema(
321 app_schema: AppSchema,
322 scopes: &[AuthLeaseScope],
323 source: &str,
324) -> Result<()> {
325 if scopes.is_empty() {
326 return Err(SyncularError::config(format!(
327 "{source} must contain at least one generated table scope"
328 )));
329 }
330 for scope in scopes {
331 validate_auth_lease_scope_against_app_schema(app_schema, scope, source)?;
332 }
333 Ok(())
334}
335
336fn validate_auth_lease_scope_against_app_schema(
337 app_schema: AppSchema,
338 scope: &AuthLeaseScope,
339 source: &str,
340) -> Result<()> {
341 if scope.subscription_id.trim().is_empty() {
342 return Err(SyncularError::config(format!(
343 "{source} scope subscriptionId must not be empty"
344 )));
345 }
346 let table = scope.table.trim();
347 if table.is_empty() {
348 return Err(SyncularError::config(format!(
349 "{source} scope table must not be empty"
350 )));
351 }
352 let metadata = app_schema.table_metadata(table).ok_or_else(|| {
353 SyncularError::config(format!(
354 "{source} scope references unknown generated table {table}"
355 ))
356 })?;
357 if scope.operations.is_empty() {
358 return Err(SyncularError::config(format!(
359 "{source} scope for table {table} must include at least one operation"
360 )));
361 }
362 for operation in &scope.operations {
363 match operation.as_str() {
364 "upsert" | "delete" => {}
365 other => {
366 return Err(SyncularError::config(format!(
367 "{source} scope for table {table} references unsupported operation {other}"
368 )));
369 }
370 }
371 }
372
373 for scope_key in scope.values.keys() {
374 if !metadata
375 .scopes
376 .iter()
377 .any(|metadata_scope| metadata_scope.name == scope_key)
378 {
379 return Err(SyncularError::config(format!(
380 "{source} scope for table {table} references unknown generated scope {scope_key}"
381 )));
382 }
383 }
384 for required_scope in metadata.scopes.iter().filter(|scope| scope.required) {
385 if !scope.values.contains_key(required_scope.name) {
386 return Err(SyncularError::config(format!(
387 "{source} scope for table {table} is missing required generated scope {}",
388 required_scope.name
389 )));
390 }
391 }
392 for (name, value) in &scope.values {
393 validate_auth_lease_scope_value(source, table, name, value)?;
394 }
395 Ok(())
396}
397
398fn validate_auth_lease_scope_value(
399 source: &str,
400 table: &str,
401 name: &str,
402 value: &Value,
403) -> Result<()> {
404 match value {
405 Value::String(value) if !value.is_empty() => Ok(()),
406 Value::Array(values)
407 if !values.is_empty()
408 && values
409 .iter()
410 .all(|value| matches!(value, Value::String(value) if !value.is_empty())) =>
411 {
412 Ok(())
413 }
414 _ => Err(SyncularError::config(format!(
415 "{source} scope {table}.{name} must be a non-empty string or non-empty string array"
416 ))),
417 }
418}
419
420#[derive(Debug, Clone, Deserialize)]
421#[serde(rename_all = "camelCase")]
422pub struct AppSchemaJson {
423 pub schema_version: i32,
424 #[serde(default)]
425 pub tables: Vec<AppTableMetadataJson>,
426 #[serde(default)]
427 pub migrations: Vec<EmbeddedMigrationJson>,
428}
429
430#[derive(Debug, Clone, Deserialize)]
431#[serde(rename_all = "camelCase")]
432pub struct EmbeddedMigrationJson {
433 pub version: String,
434 pub schema_version: i32,
435 pub name: String,
436 pub up_sql: String,
437}
438
439#[derive(Debug, Clone, Deserialize)]
440#[serde(rename_all = "camelCase")]
441pub struct AppTableMetadataJson {
442 pub name: String,
443 pub primary_key_column: String,
444 pub server_version_column: String,
445 pub soft_delete_column: Option<String>,
446 pub subscription_id: String,
447 #[serde(default)]
448 pub columns: Vec<ColumnMetadataJson>,
449 #[serde(default)]
450 pub blob_columns: Vec<String>,
451 #[serde(default)]
452 pub crdt_yjs_fields: Vec<CrdtYjsFieldMetadataJson>,
453 #[serde(default)]
454 pub encrypted_fields: Vec<EncryptedFieldMetadataJson>,
455 #[serde(default)]
456 pub scopes: Vec<ScopeMetadataJson>,
457}
458
459#[derive(Debug, Clone, Deserialize)]
460#[serde(rename_all = "camelCase")]
461pub struct ScopeMetadataJson {
462 pub name: String,
463 pub column: String,
464 pub source: ScopeSource,
465 #[serde(default)]
466 pub required: bool,
467}
468
469#[derive(Debug, Clone, Deserialize)]
470#[serde(rename_all = "camelCase")]
471pub struct ColumnMetadataJson {
472 pub name: String,
473 pub type_family: String,
474 #[serde(default)]
475 pub notnull_required: bool,
476 #[serde(default)]
477 pub primary_key: bool,
478}
479
480#[derive(Debug, Clone, Deserialize)]
481#[serde(rename_all = "camelCase")]
482pub struct CrdtYjsFieldMetadataJson {
483 pub field: String,
484 pub state_column: String,
485 pub container_key: String,
486 pub row_id_field: String,
487 pub kind: String,
488 pub sync_mode: String,
489}
490
491#[derive(Debug, Clone, Deserialize)]
492#[serde(rename_all = "camelCase")]
493pub struct EncryptedFieldMetadataJson {
494 pub field: String,
495 pub scope: String,
496 pub row_id_field: String,
497}
498
499pub fn app_schema_from_json(schema_json: &str) -> crate::error::Result<AppSchema> {
500 let schema: AppSchemaJson = serde_json::from_str(schema_json)?;
501 Ok(app_schema_from_config(schema))
502}
503
504pub fn app_schema_from_config(schema: AppSchemaJson) -> AppSchema {
505 let migrations = leak_static_slice(
506 schema
507 .migrations
508 .into_iter()
509 .map(leak_embedded_migration)
510 .collect(),
511 );
512 let app_tables = leak_static_slice(
513 schema
514 .tables
515 .iter()
516 .map(|table| leak_static_str(table.name.clone()))
517 .collect(),
518 );
519 let app_table_metadata = leak_static_slice(
520 schema
521 .tables
522 .into_iter()
523 .map(leak_app_table_metadata)
524 .collect(),
525 );
526
527 AppSchema {
528 app_tables,
529 app_table_metadata,
530 migrations,
531 schema_version: Some(schema.schema_version),
532 default_subscriptions: empty_default_subscriptions,
533 #[cfg(feature = "native")]
534 adapter_for: unknown_table_adapter,
535 }
536}
537
538pub fn empty_default_subscriptions(_: &SyncularClientConfig) -> Vec<SubscriptionSpec> {
539 Vec::new()
540}
541
542pub fn empty_app_schema(schema_version: i32) -> AppSchema {
543 AppSchema {
544 app_tables: &[],
545 app_table_metadata: &[],
546 migrations: &[],
547 schema_version: Some(schema_version),
548 default_subscriptions: empty_default_subscriptions,
549 #[cfg(feature = "native")]
550 adapter_for: unknown_table_adapter,
551 }
552}
553
554fn leak_app_table_metadata(table: AppTableMetadataJson) -> AppTableMetadata {
555 AppTableMetadata {
556 name: leak_static_str(table.name),
557 primary_key_column: leak_static_str(table.primary_key_column),
558 server_version_column: leak_static_str(table.server_version_column),
559 soft_delete_column: table.soft_delete_column.map(leak_static_str),
560 subscription_id: leak_static_str(table.subscription_id),
561 columns: leak_static_slice(
562 table
563 .columns
564 .into_iter()
565 .map(leak_column_metadata)
566 .collect(),
567 ),
568 blob_columns: leak_static_slice(
569 table
570 .blob_columns
571 .into_iter()
572 .map(leak_static_str)
573 .collect(),
574 ),
575 crdt_yjs_fields: leak_static_slice(
576 table
577 .crdt_yjs_fields
578 .into_iter()
579 .map(leak_crdt_yjs_field_metadata)
580 .collect(),
581 ),
582 encrypted_fields: leak_static_slice(
583 table
584 .encrypted_fields
585 .into_iter()
586 .map(leak_encrypted_field_metadata)
587 .collect(),
588 ),
589 scopes: leak_static_slice(table.scopes.into_iter().map(leak_scope_metadata).collect()),
590 }
591}
592
593fn leak_embedded_migration(migration: EmbeddedMigrationJson) -> EmbeddedMigration {
594 EmbeddedMigration {
595 version: leak_static_str(migration.version),
596 schema_version: migration.schema_version,
597 name: leak_static_str(migration.name),
598 up_sql: leak_static_str(migration.up_sql),
599 }
600}
601
602fn leak_scope_metadata(scope: ScopeMetadataJson) -> ScopeMetadata {
603 ScopeMetadata {
604 name: leak_static_str(scope.name),
605 column: leak_static_str(scope.column),
606 source: scope.source,
607 required: scope.required,
608 }
609}
610
611fn leak_column_metadata(column: ColumnMetadataJson) -> ColumnMetadata {
612 ColumnMetadata {
613 name: leak_static_str(column.name),
614 type_family: leak_static_str(column.type_family),
615 notnull_required: column.notnull_required,
616 primary_key: column.primary_key,
617 }
618}
619
620fn leak_crdt_yjs_field_metadata(field: CrdtYjsFieldMetadataJson) -> CrdtYjsFieldMetadata {
621 CrdtYjsFieldMetadata {
622 field: leak_static_str(field.field),
623 state_column: leak_static_str(field.state_column),
624 container_key: leak_static_str(field.container_key),
625 row_id_field: leak_static_str(field.row_id_field),
626 kind: leak_static_str(field.kind),
627 sync_mode: leak_static_str(field.sync_mode),
628 }
629}
630
631fn leak_encrypted_field_metadata(field: EncryptedFieldMetadataJson) -> EncryptedFieldMetadata {
632 EncryptedFieldMetadata {
633 field: leak_static_str(field.field),
634 scope: leak_static_str(field.scope),
635 row_id_field: leak_static_str(field.row_id_field),
636 }
637}
638
639fn leak_static_str(value: String) -> &'static str {
640 Box::leak(value.into_boxed_str())
641}
642
643fn leak_static_slice<T>(value: Vec<T>) -> &'static [T] {
644 Box::leak(value.into_boxed_slice())
645}
646
647pub fn current_schema_version(migrations: &[EmbeddedMigration]) -> i32 {
648 migrations
649 .last()
650 .map(|migration| migration.schema_version)
651 .unwrap_or(1)
652}
653
654pub fn split_sql_statements(sql: &str) -> impl Iterator<Item = String> + '_ {
655 sql.split(';')
656 .map(str::trim)
657 .filter(|statement| !statement.is_empty())
658 .map(|statement| format!("{statement};"))
659}
660
661pub fn checksum(sql: &str) -> String {
662 let digest = Sha256::digest(sql.as_bytes());
663 hex::encode(digest)
664}
665
666#[cfg(feature = "native")]
667pub fn default_app_schema() -> AppSchema {
668 empty_app_schema(crate::runtime_schema::runtime_schema_version())
669}
670
671#[cfg(not(feature = "native"))]
672pub fn default_app_schema() -> AppSchema {
673 empty_app_schema(crate::runtime_schema::runtime_schema_version())
674}
675
676#[cfg(feature = "native")]
677pub fn unknown_table_adapter(table: &str) -> Result<&'static dyn DieselTableAdapter> {
678 Err(SyncularError::message(
679 ErrorKind::Config,
680 format!("no Diesel table adapter registered for {table}"),
681 ))
682}
683
684#[cfg(test)]
685mod runtime_feature_tests {
686 use super::*;
687
688 const CRDT_FIELDS: &[CrdtYjsFieldMetadata] = &[CrdtYjsFieldMetadata {
689 field: "title",
690 state_column: "title_yjs_state",
691 container_key: "title",
692 row_id_field: "id",
693 kind: "text",
694 sync_mode: "server-merge",
695 }];
696 const ENCRYPTED_CRDT_FIELDS: &[CrdtYjsFieldMetadata] = &[CrdtYjsFieldMetadata {
697 field: "body",
698 state_column: "body_yjs_state",
699 container_key: "body",
700 row_id_field: "id",
701 kind: "text",
702 sync_mode: "encrypted-update-log",
703 }];
704 const ENCRYPTED_FIELDS: &[EncryptedFieldMetadata] = &[EncryptedFieldMetadata {
705 field: "secret",
706 scope: "tasks",
707 row_id_field: "id",
708 }];
709 const TABLES: &[&str] = &["tasks"];
710 const TABLE_COLUMNS: &[ColumnMetadata] = &[
711 ColumnMetadata {
712 name: "id",
713 type_family: "text",
714 notnull_required: false,
715 primary_key: true,
716 },
717 ColumnMetadata {
718 name: "title",
719 type_family: "text",
720 notnull_required: true,
721 primary_key: false,
722 },
723 ColumnMetadata {
724 name: "title_yjs_state",
725 type_family: "text",
726 notnull_required: false,
727 primary_key: false,
728 },
729 ColumnMetadata {
730 name: "secret",
731 type_family: "text",
732 notnull_required: false,
733 primary_key: false,
734 },
735 ColumnMetadata {
736 name: "image",
737 type_family: "text",
738 notnull_required: false,
739 primary_key: false,
740 },
741 ColumnMetadata {
742 name: "server_version",
743 type_family: "integer",
744 notnull_required: true,
745 primary_key: false,
746 },
747 ];
748 const EMPTY_SCOPES: &[ScopeMetadata] = &[];
749 const REQUIRED_SCOPES: &[ScopeMetadata] = &[ScopeMetadata {
750 name: "user_id",
751 column: "user_id",
752 source: ScopeSource::ActorId,
753 required: true,
754 }];
755 const EMPTY_BLOBS: &[&str] = &[];
756 const BLOB_COLUMNS: &[&str] = &["image"];
757 const EMPTY_CRDT_FIELDS: &[CrdtYjsFieldMetadata] = &[];
758 const EMPTY_ENCRYPTED_FIELDS: &[EncryptedFieldMetadata] = &[];
759
760 const PLAIN_TABLE_METADATA: AppTableMetadata =
761 table_metadata(EMPTY_BLOBS, EMPTY_CRDT_FIELDS, EMPTY_ENCRYPTED_FIELDS);
762 const BLOB_TABLE_METADATA: AppTableMetadata =
763 table_metadata(BLOB_COLUMNS, EMPTY_CRDT_FIELDS, EMPTY_ENCRYPTED_FIELDS);
764 const CRDT_TABLE_METADATA: AppTableMetadata =
765 table_metadata(EMPTY_BLOBS, CRDT_FIELDS, EMPTY_ENCRYPTED_FIELDS);
766 const ENCRYPTED_FIELD_TABLE_METADATA: AppTableMetadata =
767 table_metadata(EMPTY_BLOBS, EMPTY_CRDT_FIELDS, ENCRYPTED_FIELDS);
768 const ENCRYPTED_CRDT_TABLE_METADATA: AppTableMetadata =
769 table_metadata(EMPTY_BLOBS, ENCRYPTED_CRDT_FIELDS, EMPTY_ENCRYPTED_FIELDS);
770 const SCOPED_TABLE_METADATA: AppTableMetadata = table_metadata_with_scopes(
771 EMPTY_BLOBS,
772 EMPTY_CRDT_FIELDS,
773 EMPTY_ENCRYPTED_FIELDS,
774 REQUIRED_SCOPES,
775 );
776 const PLAIN_SCHEMA_TABLES: &[AppTableMetadata] = &[PLAIN_TABLE_METADATA];
777 const BLOB_SCHEMA_TABLES: &[AppTableMetadata] = &[BLOB_TABLE_METADATA];
778 const CRDT_SCHEMA_TABLES: &[AppTableMetadata] = &[CRDT_TABLE_METADATA];
779 const ENCRYPTED_FIELD_SCHEMA_TABLES: &[AppTableMetadata] = &[ENCRYPTED_FIELD_TABLE_METADATA];
780 const ENCRYPTED_CRDT_SCHEMA_TABLES: &[AppTableMetadata] = &[ENCRYPTED_CRDT_TABLE_METADATA];
781 const SCOPED_SCHEMA_TABLES: &[AppTableMetadata] = &[SCOPED_TABLE_METADATA];
782
783 const fn table_metadata(
784 blob_columns: &'static [&'static str],
785 crdt_yjs_fields: &'static [CrdtYjsFieldMetadata],
786 encrypted_fields: &'static [EncryptedFieldMetadata],
787 ) -> AppTableMetadata {
788 table_metadata_with_scopes(
789 blob_columns,
790 crdt_yjs_fields,
791 encrypted_fields,
792 EMPTY_SCOPES,
793 )
794 }
795
796 const fn table_metadata_with_scopes(
797 blob_columns: &'static [&'static str],
798 crdt_yjs_fields: &'static [CrdtYjsFieldMetadata],
799 encrypted_fields: &'static [EncryptedFieldMetadata],
800 scopes: &'static [ScopeMetadata],
801 ) -> AppTableMetadata {
802 AppTableMetadata {
803 name: "tasks",
804 primary_key_column: "id",
805 server_version_column: "server_version",
806 soft_delete_column: None,
807 subscription_id: "sub-tasks",
808 columns: TABLE_COLUMNS,
809 blob_columns,
810 crdt_yjs_fields,
811 encrypted_fields,
812 scopes,
813 }
814 }
815
816 fn schema(metadata: &'static [AppTableMetadata]) -> AppSchema {
817 AppSchema {
818 app_tables: TABLES,
819 app_table_metadata: metadata,
820 migrations: &[],
821 schema_version: Some(1),
822 default_subscriptions: empty_default_subscriptions,
823 #[cfg(feature = "native")]
824 adapter_for: unknown_table_adapter,
825 }
826 }
827
828 #[test]
829 fn plain_schema_needs_no_optional_features() {
830 assert!(validate_app_schema_runtime_features(&schema(PLAIN_SCHEMA_TABLES)).is_ok());
831 }
832
833 #[test]
834 fn blob_schema_matches_blobs_feature() {
835 let result = validate_app_schema_runtime_features(&schema(BLOB_SCHEMA_TABLES));
836 if cfg!(any(feature = "native", feature = "web-blobs")) {
837 assert!(result.is_ok());
838 } else {
839 assert!(result
840 .expect_err("blob schema should require blobs")
841 .message_text()
842 .contains("blobs"));
843 }
844 }
845
846 #[test]
847 fn crdt_schema_matches_crdt_yjs_feature() {
848 let result = validate_app_schema_runtime_features(&schema(CRDT_SCHEMA_TABLES));
849 if cfg!(feature = "crdt-yjs") {
850 assert!(result.is_ok());
851 } else {
852 assert!(result
853 .expect_err("CRDT schema should require crdt-yjs")
854 .message_text()
855 .contains("crdt-yjs"));
856 }
857 }
858
859 #[test]
860 fn encrypted_schema_matches_e2ee_feature() {
861 let result = validate_app_schema_runtime_features(&schema(ENCRYPTED_FIELD_SCHEMA_TABLES));
862 if cfg!(feature = "e2ee") {
863 assert!(result.is_ok());
864 } else {
865 assert!(result
866 .expect_err("encrypted schema should require e2ee")
867 .message_text()
868 .contains("e2ee"));
869 }
870 }
871
872 #[test]
873 fn encrypted_crdt_schema_matches_crdt_and_e2ee_features() {
874 let result = validate_app_schema_runtime_features(&schema(ENCRYPTED_CRDT_SCHEMA_TABLES));
875 if cfg!(feature = "crdt-yjs") && cfg!(feature = "e2ee") {
876 assert!(result.is_ok());
877 } else {
878 let message = result
879 .expect_err("encrypted CRDT schema should require optional features")
880 .message_text();
881 assert!(message.contains("crdt-yjs") || message.contains("e2ee"));
882 }
883 }
884
885 #[test]
886 fn field_encryption_rules_must_match_generated_metadata() {
887 let valid = FieldEncryptionRule {
888 scope: "tasks".to_string(),
889 table: Some("tasks".to_string()),
890 fields: vec!["secret".to_string()],
891 row_id_field: Some("id".to_string()),
892 };
893 assert!(validate_field_encryption_rules_against_app_schema(
894 schema(ENCRYPTED_FIELD_SCHEMA_TABLES),
895 &[valid]
896 )
897 .is_ok());
898
899 let unknown_field = FieldEncryptionRule {
900 scope: "tasks".to_string(),
901 table: Some("tasks".to_string()),
902 fields: vec!["title".to_string()],
903 row_id_field: Some("id".to_string()),
904 };
905 assert!(validate_field_encryption_rules_against_app_schema(
906 schema(ENCRYPTED_FIELD_SCHEMA_TABLES),
907 &[unknown_field]
908 )
909 .expect_err("runtime-only encryption fields should fail")
910 .message_text()
911 .contains("not declared"));
912
913 let wildcard_table = FieldEncryptionRule {
914 scope: "tasks".to_string(),
915 table: None,
916 fields: vec!["secret".to_string()],
917 row_id_field: Some("id".to_string()),
918 };
919 assert!(validate_field_encryption_rules_against_app_schema(
920 schema(ENCRYPTED_FIELD_SCHEMA_TABLES),
921 &[wildcard_table]
922 )
923 .expect_err("field encryption rules must be table-specific")
924 .message_text()
925 .contains("must specify"));
926 }
927
928 #[test]
929 fn blob_encryption_requires_generated_blob_column() {
930 assert!(validate_blob_encryption_against_app_schema(schema(BLOB_SCHEMA_TABLES)).is_ok());
931 assert!(
932 validate_blob_encryption_against_app_schema(schema(PLAIN_SCHEMA_TABLES))
933 .expect_err("blob encryption without blob fields should fail")
934 .message_text()
935 .contains("blob column")
936 );
937 }
938
939 #[test]
940 fn encrypted_crdt_requires_generated_encrypted_crdt_field() {
941 assert!(
942 validate_encrypted_crdt_against_app_schema(schema(ENCRYPTED_CRDT_SCHEMA_TABLES))
943 .is_ok()
944 );
945 assert!(
946 validate_encrypted_crdt_against_app_schema(schema(CRDT_SCHEMA_TABLES))
947 .expect_err("encrypted CRDT config without encrypted-update-log fields should fail")
948 .message_text()
949 .contains("encrypted-update-log")
950 );
951 }
952
953 #[test]
954 fn auth_lease_issue_request_must_match_generated_scope_metadata() {
955 let valid = AuthLeaseIssueRequest {
956 schema_version: 1,
957 ttl_ms: Some(60_000),
958 scopes: vec![AuthLeaseScope {
959 subscription_id: "custom-sub-tasks".to_string(),
960 table: "tasks".to_string(),
961 values: serde_json::json!({ "user_id": ["user-rust"] })
962 .as_object()
963 .expect("scope object")
964 .clone(),
965 operations: vec!["upsert".to_string(), "delete".to_string()],
966 }],
967 };
968 assert!(validate_auth_lease_issue_request_against_app_schema(
969 schema(SCOPED_SCHEMA_TABLES),
970 &valid
971 )
972 .is_ok());
973
974 let unknown_scope = AuthLeaseIssueRequest {
975 schema_version: 1,
976 ttl_ms: None,
977 scopes: vec![AuthLeaseScope {
978 subscription_id: "sub-tasks".to_string(),
979 table: "tasks".to_string(),
980 values: serde_json::json!({ "project_id": "p0" })
981 .as_object()
982 .expect("scope object")
983 .clone(),
984 operations: vec!["upsert".to_string()],
985 }],
986 };
987 assert!(validate_auth_lease_issue_request_against_app_schema(
988 schema(SCOPED_SCHEMA_TABLES),
989 &unknown_scope
990 )
991 .expect_err("unknown generated lease scopes should fail")
992 .message_text()
993 .contains("unknown generated scope project_id"));
994
995 let missing_required_scope = AuthLeaseIssueRequest {
996 schema_version: 1,
997 ttl_ms: None,
998 scopes: vec![AuthLeaseScope {
999 subscription_id: "sub-tasks".to_string(),
1000 table: "tasks".to_string(),
1001 values: serde_json::Map::new(),
1002 operations: vec!["upsert".to_string()],
1003 }],
1004 };
1005 assert!(validate_auth_lease_issue_request_against_app_schema(
1006 schema(SCOPED_SCHEMA_TABLES),
1007 &missing_required_scope
1008 )
1009 .expect_err("missing generated lease scopes should fail")
1010 .message_text()
1011 .contains("missing required generated scope user_id"));
1012 }
1013}