1use std::collections::HashMap;
6
7use serde_json::{Map, Value};
8use ssp2::primitives::{RawJson, Reader, Writer};
9use ssp2::segment::{decode_row, encode_row, Column, ColumnType, ColumnValue, Row};
10use ssp2::util::utf16_lt;
11
12use crate::schema::TableSchema;
13
14#[derive(Debug, Clone, Default)]
19pub struct EncryptionConfig {
20 pub keys: HashMap<String, Vec<u8>>,
21 pub key_id_columns: HashMap<String, String>,
24}
25
26impl Drop for EncryptionConfig {
27 fn drop(&mut self) {
28 for key in self.keys.values_mut() {
31 key.fill(0);
32 }
33 }
34}
35
36impl EncryptionConfig {
37 pub fn is_empty(&self) -> bool {
38 self.keys.is_empty()
39 }
40
41 pub fn key_id_for(&self, table: &TableSchema, row: &Row) -> Result<String, String> {
43 let Some(column_name) = self.key_id_columns.get(&table.name) else {
44 return Ok(table.name.clone());
45 };
46 let Some(index) = table
47 .columns
48 .iter()
49 .position(|column| &column.name == column_name)
50 else {
51 return Err(format!(
52 "client.decrypt_failed: encryption key-id column {column_name:?} is not present on table {:?}",
53 table.name
54 ));
55 };
56 if table
57 .encrypted_columns
58 .iter()
59 .any(|column| column.index == index)
60 {
61 return Err(format!(
62 "client.decrypt_failed: encryption key-id column {column_name:?} on table {:?} must not be encrypted",
63 table.name
64 ));
65 }
66 match row.get(index).and_then(|value| value.as_ref()) {
67 Some(ColumnValue::String(key_id)) if !key_id.is_empty() => Ok(key_id.clone()),
68 _ => Err(format!(
69 "client.decrypt_failed: encryption key-id column {column_name:?} on table {:?} must contain a non-empty string",
70 table.name
71 )),
72 }
73 }
74}
75
76pub fn snake_to_camel(name: &str) -> String {
81 let is_mappable = {
82 let bare = name.trim_start_matches('_');
83 !bare.is_empty()
84 && bare.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
85 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
86 };
87 if !is_mappable {
88 return name.to_owned();
89 }
90 let lead_len = name.len() - name.trim_start_matches('_').len();
91 let (lead, bare) = name.split_at(lead_len);
92 let trail_len = bare.len() - bare.trim_end_matches('_').len();
93 let (middle, trail) = bare.split_at(bare.len() - trail_len);
94 let mut segments = middle.split('_').filter(|s| !s.is_empty());
95 let Some(first) = segments.next() else {
96 return name.to_owned();
97 };
98 let mut out = String::with_capacity(name.len());
99 out.push_str(lead);
100 out.push_str(first);
101 for segment in segments {
102 let mut chars = segment.chars();
103 if let Some(head) = chars.next() {
104 out.push(head.to_ascii_uppercase());
105 out.push_str(chars.as_str());
106 }
107 }
108 out.push_str(trail);
109 out
110}
111
112pub fn normalize_values_casing(
120 table: &TableSchema,
121 mut values: Map<String, Value>,
122) -> Result<Map<String, Value>, String> {
123 for column in &table.columns {
124 let camel = snake_to_camel(&column.name);
125 if camel == column.name {
126 continue;
127 }
128 if table.columns.iter().any(|c| c.name == camel) {
131 continue;
132 }
133 if table
134 .columns
135 .iter()
136 .filter(|c| snake_to_camel(&c.name) == camel)
137 .count()
138 > 1
139 {
140 continue;
141 }
142 if let Some(value) = values.remove(&camel) {
143 if values.contains_key(&column.name) {
144 return Err(format!(
145 "table {:?}: column {:?} appears twice in mutation values (as both snake_case and camelCase) — pass it once",
146 table.name, column.name
147 ));
148 }
149 values.insert(column.name.clone(), value);
150 }
151 }
152 for key in values.keys() {
153 if !table.columns.iter().any(|column| column.name == *key) {
154 if key.starts_with("_sync_") {
155 return Err(format!(
156 "table {:?}: {:?} is an internal sync column and cannot appear in mutation values",
157 table.name, key
158 ));
159 }
160 return Err(format!(
161 "table {:?}: unknown column {:?} in mutation values (snake_case and camelCase keys are accepted)",
162 table.name, key
163 ));
164 }
165 }
166 Ok(values)
167}
168
169pub fn bytes_to_hex(bytes: &[u8]) -> String {
170 let mut out = String::with_capacity(bytes.len() * 2);
171 for b in bytes {
172 out.push_str(&format!("{b:02x}"));
173 }
174 out
175}
176
177pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
178 if !hex.len().is_multiple_of(2) {
179 return Err("odd-length hex string".to_owned());
180 }
181 let mut out = Vec::with_capacity(hex.len() / 2);
182 let bytes = hex.as_bytes();
183 for pair in bytes.chunks(2) {
184 let s = std::str::from_utf8(pair).map_err(|_| "non-ASCII hex".to_owned())?;
185 out.push(u8::from_str_radix(s, 16).map_err(|e| format!("bad hex: {e}"))?);
186 }
187 Ok(out)
188}
189
190pub fn json_to_column_value(
193 column: &Column,
194 value: Option<&Value>,
195) -> Result<Option<ColumnValue>, String> {
196 let value = match value {
197 None | Some(Value::Null) => return Ok(None),
198 Some(v) => v,
199 };
200 let fail = |expected: &str| {
201 Err(format!(
202 "column {:?}: expected {expected}, got {value}",
203 column.name
204 ))
205 };
206 match column.ty {
207 ColumnType::String => match value.as_str() {
208 Some(s) => Ok(Some(ColumnValue::String(s.to_owned()))),
209 None => fail("a string"),
210 },
211 ColumnType::Integer => match value.as_i64() {
212 Some(i) => Ok(Some(ColumnValue::Integer(i))),
213 None => fail("an integer"),
214 },
215 ColumnType::Float => match value.as_f64() {
216 Some(f) => Ok(Some(ColumnValue::Float(f))),
217 None => fail("a number"),
218 },
219 ColumnType::Boolean => match value.as_bool() {
220 Some(b) => Ok(Some(ColumnValue::Boolean(b))),
221 None => fail("a boolean"),
222 },
223 ColumnType::Json => match value.as_str() {
225 Some(s) => Ok(Some(ColumnValue::Json(RawJson(s.to_owned())))),
226 None => fail("a raw JSON string"),
227 },
228 ColumnType::BlobRef => match value.as_str() {
230 Some(s) => Ok(Some(ColumnValue::BlobRef(RawJson(s.to_owned())))),
231 None => fail("a raw BlobRef JSON string"),
232 },
233 ColumnType::Bytes => match value.get("$bytes").and_then(Value::as_str) {
234 Some(hex) => Ok(Some(ColumnValue::Bytes(hex_to_bytes(hex)?))),
235 None => fail("a {\"$bytes\": hex} object"),
236 },
237 ColumnType::Crdt => match value.get("$bytes").and_then(Value::as_str) {
239 Some(hex) => Ok(Some(ColumnValue::Crdt(hex_to_bytes(hex)?))),
240 None => fail("a {\"$bytes\": hex} object"),
241 },
242 }
243}
244
245pub fn column_value_to_json(value: &Option<ColumnValue>) -> Value {
247 match value {
248 None => Value::Null,
249 Some(ColumnValue::String(s)) => Value::from(s.clone()),
250 Some(ColumnValue::Integer(i)) => Value::from(*i),
251 Some(ColumnValue::Float(f)) => {
252 serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number)
253 }
254 Some(ColumnValue::Boolean(b)) => Value::from(*b),
255 Some(ColumnValue::Json(raw)) => Value::from(raw.0.clone()),
256 Some(ColumnValue::BlobRef(raw)) => Value::from(raw.0.clone()),
257 Some(ColumnValue::Bytes(bytes)) => {
258 let mut map = Map::new();
259 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
260 Value::Object(map)
261 }
262 Some(ColumnValue::Crdt(bytes)) => {
263 let mut map = Map::new();
264 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
265 Value::Object(map)
266 }
267 }
268}
269
270pub fn encode_row_json(
275 table: &TableSchema,
276 row_id: &str,
277 values: &Map<String, Value>,
278 encryption: &EncryptionConfig,
279) -> Result<Vec<u8>, String> {
280 let mut row: Row = Vec::with_capacity(table.columns.len());
282 for column in &table.columns {
283 let value = json_to_column_value(column, values.get(&column.name))?;
284 if value.is_none() && !column.nullable {
285 return Err(format!(
286 "table {:?}: column {:?} is not nullable (§6.1 full-row payloads)",
287 table.name, column.name
288 ));
289 }
290 row.push(value);
291 }
292 if table.has_encrypted_columns() {
293 encrypt_row(table, row_id, &mut row, encryption)?;
294 }
295 let mut w = Writer::new();
297 encode_row(&mut w, &table.wire_columns, &row);
298 Ok(w.into_bytes())
299}
300
301pub fn decode_row_bytes(
305 table: &TableSchema,
306 payload: &[u8],
307 encryption: &EncryptionConfig,
308) -> Result<Row, String> {
309 let mut r = Reader::new(payload);
310 let mut row = decode_row(&mut r, &table.wire_columns).map_err(|e| e.to_string())?;
312 if !r.is_empty() {
313 return Err("row payload has trailing bytes".to_owned());
314 }
315 if table.has_encrypted_columns() {
316 decrypt_row(table, &mut row, encryption)?;
317 }
318 Ok(row)
319}
320
321pub fn decrypt_segment_row(
325 table: &TableSchema,
326 row: &mut Row,
327 encryption: &EncryptionConfig,
328) -> Result<(), String> {
329 decrypt_row(table, row, encryption)
330}
331
332#[cfg(feature = "e2ee")]
335fn encrypt_row(
336 table: &TableSchema,
337 _row_id: &str,
338 row: &mut Row,
339 encryption: &EncryptionConfig,
340) -> Result<(), String> {
341 use rand_core::RngCore;
342 use ssp2::crypto::{encrypt_value, NONCE_LENGTH};
343 for enc in &table.encrypted_columns {
344 let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
345 continue; };
347 let plain = column_value_to_plain(value)?;
348 let key_id = encryption.key_id_for(table, row)?;
349 let key = encryption
350 .keys
351 .get(&key_id)
352 .ok_or_else(|| format!("client.decrypt_failed: no key for keyId {key_id:?}"))?;
353 let mut nonce = [0u8; NONCE_LENGTH];
354 rand_core::OsRng.fill_bytes(&mut nonce);
355 let envelope = encrypt_value(&plain, &key_id, key, nonce)?;
356 row[enc.index] = Some(ColumnValue::Bytes(envelope));
357 }
358 Ok(())
359}
360
361#[cfg(feature = "e2ee")]
362fn decrypt_row(
363 table: &TableSchema,
364 row: &mut Row,
365 encryption: &EncryptionConfig,
366) -> Result<(), String> {
367 use ssp2::crypto::{decrypt_value, DeclaredType};
368 for enc in &table.encrypted_columns {
369 let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
370 continue;
371 };
372 let ColumnValue::Bytes(envelope) = value else {
373 return Err(format!(
374 "client.decrypt_failed: encrypted column at index {} is not bytes",
375 enc.index
376 ));
377 };
378 let declared = DeclaredType::from_name(&enc.declared_type)
379 .ok_or_else(|| format!("unknown declaredType {:?}", enc.declared_type))?;
380 let keys = &encryption.keys;
381 let plain = decrypt_value(declared, envelope, |id| keys.get(id).cloned())
382 .map_err(|e| e.to_string())?;
383 row[enc.index] = Some(plain_to_column_value(plain));
384 }
385 Ok(())
386}
387
388#[cfg(not(feature = "e2ee"))]
391fn encrypt_row(
392 table: &TableSchema,
393 _row_id: &str,
394 _row: &mut Row,
395 _encryption: &EncryptionConfig,
396) -> Result<(), String> {
397 Err(format!(
398 "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
399 table.name
400 ))
401}
402
403#[cfg(not(feature = "e2ee"))]
404fn decrypt_row(
405 table: &TableSchema,
406 _row: &mut Row,
407 _encryption: &EncryptionConfig,
408) -> Result<(), String> {
409 Err(format!(
410 "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
411 table.name
412 ))
413}
414
415#[cfg(feature = "e2ee")]
416fn column_value_to_plain(value: &ColumnValue) -> Result<ssp2::crypto::PlainValue, String> {
417 use ssp2::crypto::PlainValue;
418 Ok(match value {
419 ColumnValue::String(s) => PlainValue::String(s.clone()),
420 ColumnValue::Integer(i) => PlainValue::Integer(*i),
421 ColumnValue::Float(f) => PlainValue::Float(*f),
422 ColumnValue::Boolean(b) => PlainValue::Boolean(*b),
423 ColumnValue::Json(j) => PlainValue::Json(j.0.clone()),
424 ColumnValue::BlobRef(j) => PlainValue::BlobRef(j.0.clone()),
425 ColumnValue::Bytes(b) => PlainValue::Bytes(b.clone()),
426 ColumnValue::Crdt(_) => return Err("crdt columns cannot be encrypted (§5.11)".to_owned()),
427 })
428}
429
430#[cfg(feature = "e2ee")]
431fn plain_to_column_value(value: ssp2::crypto::PlainValue) -> ColumnValue {
432 use ssp2::crypto::PlainValue;
433 match value {
434 PlainValue::String(s) => ColumnValue::String(s),
435 PlainValue::Integer(i) => ColumnValue::Integer(i),
436 PlainValue::Float(f) => ColumnValue::Float(f),
437 PlainValue::Boolean(b) => ColumnValue::Boolean(b),
438 PlainValue::Json(s) => ColumnValue::Json(RawJson(s)),
439 PlainValue::BlobRef(s) => ColumnValue::BlobRef(RawJson(s)),
440 PlainValue::Bytes(b) => ColumnValue::Bytes(b),
441 }
442}
443
444pub fn render_row_id(value: &Option<ColumnValue>) -> Result<String, String> {
446 match value {
447 Some(ColumnValue::String(s)) => Ok(s.clone()),
448 Some(ColumnValue::Integer(i)) => Ok(i.to_string()),
449 Some(ColumnValue::Float(f)) => Ok(f.to_string()),
450 Some(ColumnValue::Boolean(b)) => Ok(b.to_string()),
451 Some(ColumnValue::Json(raw)) => Ok(raw.0.clone()),
452 Some(ColumnValue::BlobRef(_)) => Err("blob_ref column cannot be a rowId".to_owned()),
453 Some(ColumnValue::Bytes(_)) => Err("bytes column cannot be a rowId".to_owned()),
454 Some(ColumnValue::Crdt(_)) => Err("crdt column cannot be a rowId".to_owned()),
455 None => Err("primary key value is missing".to_owned()),
456 }
457}
458
459pub fn render_row_id_json(value: Option<&Value>) -> Result<String, String> {
461 match value {
462 Some(Value::String(s)) => Ok(s.clone()),
463 Some(Value::Number(n)) => Ok(n.to_string()),
464 Some(Value::Bool(b)) => Ok(b.to_string()),
465 _ => Err("primary key value is missing or not renderable".to_owned()),
466 }
467}
468
469fn sort_utf16(values: &mut [String]) {
470 values.sort_by(|a, b| {
471 if utf16_lt(a, b) {
472 std::cmp::Ordering::Less
473 } else if utf16_lt(b, a) {
474 std::cmp::Ordering::Greater
475 } else {
476 std::cmp::Ordering::Equal
477 }
478 });
479}
480
481pub fn sort_scope_map(map: &mut [(String, Vec<String>)]) {
484 map.sort_by(|a, b| {
485 if utf16_lt(&a.0, &b.0) {
486 std::cmp::Ordering::Less
487 } else if utf16_lt(&b.0, &a.0) {
488 std::cmp::Ordering::Greater
489 } else {
490 std::cmp::Ordering::Equal
491 }
492 });
493}
494
495pub fn canonical_scope_json(scopes: &[(String, Vec<String>)]) -> String {
498 let mut entries: Vec<(String, Vec<String>)> = scopes.to_vec();
499 sort_scope_map(&mut entries);
500 let mut out = String::from("{");
501 for (i, (key, values)) in entries.iter().enumerate() {
502 if i > 0 {
503 out.push(',');
504 }
505 let mut sorted = values.clone();
506 sort_utf16(&mut sorted);
507 sorted.dedup();
508 out.push_str(&serde_json::to_string(key).expect("string serializes"));
509 out.push_str(":[");
510 for (j, value) in sorted.iter().enumerate() {
511 if j > 0 {
512 out.push(',');
513 }
514 out.push_str(&serde_json::to_string(value).expect("string serializes"));
515 }
516 out.push(']');
517 }
518 out.push('}');
519 out
520}
521
522pub fn scope_map_to_json(scopes: &[(String, Vec<String>)]) -> Value {
524 let mut map = Map::new();
525 for (key, values) in scopes {
526 map.insert(
527 key.clone(),
528 Value::Array(values.iter().map(|v| Value::from(v.clone())).collect()),
529 );
530 }
531 Value::Object(map)
532}
533
534pub fn json_to_scope_map(value: &Value) -> Result<Vec<(String, Vec<String>)>, String> {
536 let object = value
537 .as_object()
538 .ok_or_else(|| "scope map must be an object".to_owned())?;
539 let mut out = Vec::with_capacity(object.len());
540 for (key, values) in object {
541 let list = values
542 .as_array()
543 .ok_or_else(|| format!("scope values for {key:?} must be a list (§0)"))?;
544 let mut strings = Vec::with_capacity(list.len());
545 for v in list {
546 strings.push(
547 v.as_str()
548 .ok_or_else(|| format!("scope value for {key:?} is not a string"))?
549 .to_owned(),
550 );
551 }
552 out.push((key.clone(), strings));
553 }
554 Ok(out)
555}
556
557#[cfg(test)]
558mod naming_tests {
559 use serde_json::{json, Map, Value};
560 use ssp2::segment::{Column, ColumnType, ColumnValue, Row};
561
562 use super::{normalize_values_casing, snake_to_camel, EncryptionConfig};
563 use crate::schema::{EncryptedColumn, TableSchema};
564
565 #[test]
566 fn snake_to_camel_pinned_vectors() {
567 for (input, expected) in [
568 ("created_at", "createdAt"),
569 ("col_2", "col2"),
570 ("user_id", "userId"),
571 ("_internal", "_internal"),
572 ("__foo_bar", "__fooBar"),
573 ("row_", "row_"),
574 ("id_url", "idUrl"),
575 ("api_key", "apiKey"),
576 ("title", "title"),
577 ("alreadyCamel", "alreadyCamel"),
578 ("a__b", "aB"),
579 ("_lead_and_trail_", "_leadAndTrail_"),
580 ("count(*)", "count(*)"),
581 ] {
582 assert_eq!(snake_to_camel(input), expected, "input {input:?}");
583 }
584 }
585
586 #[test]
587 fn portable_key_selector_reads_a_non_encrypted_string_column() {
588 let columns = vec![
589 Column {
590 name: "id".to_owned(),
591 ty: ColumnType::String,
592 nullable: false,
593 },
594 Column {
595 name: "encryption_key_id".to_owned(),
596 ty: ColumnType::String,
597 nullable: false,
598 },
599 Column {
600 name: "note".to_owned(),
601 ty: ColumnType::String,
602 nullable: false,
603 },
604 ];
605 let table = TableSchema {
606 name: "patients".to_owned(),
607 columns: columns.clone(),
608 wire_columns: columns,
609 primary_key: "id".to_owned(),
610 pk_index: 0,
611 scope_variables: Vec::new(),
612 indexes: Vec::new(),
613 fts_indexes: Vec::new(),
614 encrypted_columns: vec![EncryptedColumn {
615 index: 2,
616 declared_type: "string".to_owned(),
617 }],
618 };
619 let mut config = EncryptionConfig::default();
620 config
621 .key_id_columns
622 .insert("patients".to_owned(), "encryption_key_id".to_owned());
623 let row: Row = vec![
624 Some(ColumnValue::String("patient-1".to_owned())),
625 Some(ColumnValue::String("practice-key-v1".to_owned())),
626 Some(ColumnValue::String("Identity".to_owned())),
627 ];
628 assert_eq!(
629 config.key_id_for(&table, &row).expect("selects key"),
630 "practice-key-v1"
631 );
632 }
633
634 fn table(names: &[&str]) -> TableSchema {
635 let columns: Vec<Column> = names
636 .iter()
637 .map(|n| Column {
638 name: (*n).to_owned(),
639 ty: ColumnType::String,
640 nullable: true,
641 })
642 .collect();
643 TableSchema {
644 name: "t".to_owned(),
645 columns: columns.clone(),
646 wire_columns: columns,
647 primary_key: "id".to_owned(),
648 pk_index: 0,
649 scope_variables: Vec::new(),
650 indexes: Vec::new(),
651 fts_indexes: Vec::new(),
652 encrypted_columns: Vec::new(),
653 }
654 }
655
656 fn map(entries: &[(&str, &str)]) -> Map<String, Value> {
657 entries
658 .iter()
659 .map(|(k, v)| ((*k).to_owned(), json!(v)))
660 .collect()
661 }
662
663 #[test]
664 fn camel_keys_normalize_to_sql_names() {
665 let t = table(&["id", "list_id", "updated_at_ms"]);
666 let out = normalize_values_casing(
667 &t,
668 map(&[("id", "x"), ("listId", "l"), ("updatedAtMs", "9")]),
669 )
670 .expect("normalizes");
671 assert_eq!(out.get("list_id"), Some(&json!("l")));
672 assert_eq!(out.get("updated_at_ms"), Some(&json!("9")));
673 assert!(!out.contains_key("listId"));
674 }
675
676 #[test]
677 fn snake_keys_pass_through() {
678 let t = table(&["id", "list_id"]);
679 let out = normalize_values_casing(&t, map(&[("id", "x"), ("list_id", "l")])).expect("ok");
680 assert_eq!(out.get("list_id"), Some(&json!("l")));
681 }
682
683 #[test]
684 fn both_casings_for_one_column_is_an_error() {
685 let t = table(&["id", "list_id"]);
686 let err = normalize_values_casing(&t, map(&[("list_id", "a"), ("listId", "b")]))
687 .expect_err("rejects");
688 assert!(err.contains("both snake_case and camelCase"), "{err}");
689 }
690
691 #[test]
692 fn an_alias_colliding_with_a_real_column_never_steals_it() {
693 let t = table(&["id", "col_2", "col2"]);
695 let out = normalize_values_casing(&t, map(&[("id", "x"), ("col2", "v")])).expect("ok");
696 assert_eq!(out.get("col2"), Some(&json!("v")));
697 assert!(!out.contains_key("col_2"));
698 }
699
700 #[test]
701 fn unknown_and_internal_columns_fail_loud() {
702 let t = table(&["id", "title"]);
703 let unknown = normalize_values_casing(&t, map(&[("id", "x"), ("typo", "v")]))
704 .expect_err("unknown field");
705 assert!(unknown.contains("unknown column"), "{unknown}");
706 let internal = normalize_values_casing(&t, map(&[("id", "x"), ("_sync_version", "1")]))
707 .expect_err("internal field");
708 assert!(internal.contains("internal sync column"), "{internal}");
709 }
710}