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