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(Clone, Default)]
19pub struct EncryptionConfig {
20 pub keys: HashMap<String, Vec<u8>>,
21 pub key_id_columns: HashMap<String, String>,
24}
25
26impl std::fmt::Debug for EncryptionConfig {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 struct Redacted(usize);
31 impl std::fmt::Debug for Redacted {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 write!(f, "<{} bytes>", self.0)
34 }
35 }
36 let keys: std::collections::BTreeMap<&String, Redacted> = self
37 .keys
38 .iter()
39 .map(|(id, key)| (id, Redacted(key.len())))
40 .collect();
41 f.debug_struct("EncryptionConfig")
42 .field("keys", &keys)
43 .field("key_id_columns", &self.key_id_columns)
44 .finish()
45 }
46}
47
48impl Drop for EncryptionConfig {
49 fn drop(&mut self) {
50 for key in self.keys.values_mut() {
53 key.fill(0);
54 }
55 }
56}
57
58impl EncryptionConfig {
59 pub fn is_empty(&self) -> bool {
60 self.keys.is_empty()
61 }
62
63 pub fn key_id_for(&self, table: &TableSchema, row: &Row) -> Result<String, String> {
65 let Some(column_name) = self.key_id_columns.get(&table.name) else {
66 return Ok(table.name.clone());
67 };
68 let Some(index) = table
69 .columns
70 .iter()
71 .position(|column| &column.name == column_name)
72 else {
73 return Err(format!(
74 "client.decrypt_failed: encryption key-id column {column_name:?} is not present on table {:?}",
75 table.name
76 ));
77 };
78 if table
79 .encrypted_columns
80 .iter()
81 .any(|column| column.index == index)
82 {
83 return Err(format!(
84 "client.decrypt_failed: encryption key-id column {column_name:?} on table {:?} must not be encrypted",
85 table.name
86 ));
87 }
88 match row.get(index).and_then(|value| value.as_ref()) {
89 Some(ColumnValue::String(key_id)) if !key_id.is_empty() => Ok(key_id.clone()),
90 _ => Err(format!(
91 "client.decrypt_failed: encryption key-id column {column_name:?} on table {:?} must contain a non-empty string",
92 table.name
93 )),
94 }
95 }
96}
97
98pub fn snake_to_camel(name: &str) -> String {
103 let is_mappable = {
104 let bare = name.trim_start_matches('_');
105 !bare.is_empty()
106 && bare.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
107 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
108 };
109 if !is_mappable {
110 return name.to_owned();
111 }
112 let lead_len = name.len() - name.trim_start_matches('_').len();
113 let (lead, bare) = name.split_at(lead_len);
114 let trail_len = bare.len() - bare.trim_end_matches('_').len();
115 let (middle, trail) = bare.split_at(bare.len() - trail_len);
116 let mut segments = middle.split('_').filter(|s| !s.is_empty());
117 let Some(first) = segments.next() else {
118 return name.to_owned();
119 };
120 let mut out = String::with_capacity(name.len());
121 out.push_str(lead);
122 out.push_str(first);
123 for segment in segments {
124 let mut chars = segment.chars();
125 if let Some(head) = chars.next() {
126 out.push(head.to_ascii_uppercase());
127 out.push_str(chars.as_str());
128 }
129 }
130 out.push_str(trail);
131 out
132}
133
134pub fn normalize_values_casing(
142 table: &TableSchema,
143 mut values: Map<String, Value>,
144) -> Result<Map<String, Value>, String> {
145 for column in &table.columns {
146 let camel = snake_to_camel(&column.name);
147 if camel == column.name {
148 continue;
149 }
150 if table.columns.iter().any(|c| c.name == camel) {
153 continue;
154 }
155 if table
156 .columns
157 .iter()
158 .filter(|c| snake_to_camel(&c.name) == camel)
159 .count()
160 > 1
161 {
162 continue;
163 }
164 if let Some(value) = values.remove(&camel) {
165 if values.contains_key(&column.name) {
166 return Err(format!(
167 "table {:?}: column {:?} appears twice in mutation values (as both snake_case and camelCase) — pass it once",
168 table.name, column.name
169 ));
170 }
171 values.insert(column.name.clone(), value);
172 }
173 }
174 for key in values.keys() {
175 if !table.columns.iter().any(|column| column.name == *key) {
176 if key.starts_with("_sync_") {
177 return Err(format!(
178 "table {:?}: {:?} is an internal sync column and cannot appear in mutation values",
179 table.name, key
180 ));
181 }
182 return Err(format!(
183 "table {:?}: unknown column {:?} in mutation values (snake_case and camelCase keys are accepted)",
184 table.name, key
185 ));
186 }
187 }
188 Ok(values)
189}
190
191pub fn bytes_to_hex(bytes: &[u8]) -> String {
192 let mut out = String::with_capacity(bytes.len() * 2);
193 for b in bytes {
194 out.push(char::from(b"0123456789abcdef"[usize::from(b >> 4)]));
195 out.push(char::from(b"0123456789abcdef"[usize::from(b & 15)]));
196 }
197 out
198}
199
200pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
201 if !hex.len().is_multiple_of(2) {
202 return Err("odd-length hex string".to_owned());
203 }
204 let mut out = Vec::with_capacity(hex.len() / 2);
205 let nibble = |byte| match byte {
206 b'0'..=b'9' => byte - b'0',
207 b'a'..=b'f' => byte - b'a' + 10,
208 b'A'..=b'F' => byte - b'A' + 10,
209 _ => u8::MAX,
210 };
211 for pair in hex.as_bytes().as_chunks::<2>().0 {
212 let high = if pair[0] == b'+' { 0 } else { nibble(pair[0]) };
215 let low = nibble(pair[1]);
216 if high == u8::MAX || low == u8::MAX {
217 return Err(if std::str::from_utf8(pair).is_err() {
218 "non-ASCII hex".to_owned()
219 } else {
220 "bad hex: invalid digit found in string".to_owned()
221 });
222 }
223 out.push((high << 4) | low);
224 }
225 Ok(out)
226}
227
228pub fn json_to_column_value(
231 column: &Column,
232 value: Option<&Value>,
233) -> Result<Option<ColumnValue>, String> {
234 let value = match value {
235 None | Some(Value::Null) => return Ok(None),
236 Some(v) => v,
237 };
238 let fail = |expected: &str| {
239 Err(format!(
240 "column {:?}: expected {expected}, got {value}",
241 column.name
242 ))
243 };
244 match column.ty {
245 ColumnType::String => match value.as_str() {
246 Some(s) => Ok(Some(ColumnValue::String(s.to_owned()))),
247 None => fail("a string"),
248 },
249 ColumnType::Integer => match value.as_i64() {
250 Some(i) => Ok(Some(ColumnValue::Integer(i))),
251 None => fail("an integer"),
252 },
253 ColumnType::Float => match value.as_f64() {
254 Some(f) => Ok(Some(ColumnValue::Float(f))),
255 None => fail("a number"),
256 },
257 ColumnType::Boolean => match value.as_bool() {
258 Some(b) => Ok(Some(ColumnValue::Boolean(b))),
259 None => fail("a boolean"),
260 },
261 ColumnType::Json => match value.as_str() {
263 Some(s) => Ok(Some(ColumnValue::Json(RawJson(s.to_owned())))),
264 None => fail("a raw JSON string"),
265 },
266 ColumnType::BlobRef => match value.as_str() {
268 Some(s) => Ok(Some(ColumnValue::BlobRef(RawJson(s.to_owned())))),
269 None => fail("a raw BlobRef JSON string"),
270 },
271 ColumnType::Bytes => match value.get("$bytes").and_then(Value::as_str) {
272 Some(hex) => Ok(Some(ColumnValue::Bytes(hex_to_bytes(hex)?))),
273 None => fail("a {\"$bytes\": hex} object"),
274 },
275 ColumnType::Crdt => match value.get("$bytes").and_then(Value::as_str) {
277 Some(hex) => Ok(Some(ColumnValue::Crdt(hex_to_bytes(hex)?))),
278 None => fail("a {\"$bytes\": hex} object"),
279 },
280 }
281}
282
283pub fn column_value_to_json(value: &Option<ColumnValue>) -> Value {
285 match value {
286 None => Value::Null,
287 Some(ColumnValue::String(s)) => Value::from(s.clone()),
288 Some(ColumnValue::Integer(i)) => Value::from(*i),
289 Some(ColumnValue::Float(f)) => {
290 serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number)
291 }
292 Some(ColumnValue::Boolean(b)) => Value::from(*b),
293 Some(ColumnValue::Json(raw)) => Value::from(raw.0.clone()),
294 Some(ColumnValue::BlobRef(raw)) => Value::from(raw.0.clone()),
295 Some(ColumnValue::Bytes(bytes)) => {
296 let mut map = Map::new();
297 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
298 Value::Object(map)
299 }
300 Some(ColumnValue::Crdt(bytes)) => {
301 let mut map = Map::new();
302 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
303 Value::Object(map)
304 }
305 }
306}
307
308pub fn encode_row_json(
313 table: &TableSchema,
314 row_id: &str,
315 values: &Map<String, Value>,
316 encryption: &EncryptionConfig,
317) -> Result<Vec<u8>, String> {
318 let mut row: Row = Vec::with_capacity(table.columns.len());
320 for column in &table.columns {
321 let value = json_to_column_value(column, values.get(&column.name))?;
322 if value.is_none() && !column.nullable {
323 return Err(format!(
324 "table {:?}: column {:?} is not nullable (§6.1 full-row payloads)",
325 table.name, column.name
326 ));
327 }
328 row.push(value);
329 }
330 if table.has_encrypted_columns() {
331 encrypt_row(table, row_id, &mut row, encryption)?;
332 }
333 let mut w = Writer::new();
335 encode_row(&mut w, &table.wire_columns, &row);
336 Ok(w.into_bytes())
337}
338
339pub fn decode_row_bytes(
343 table: &TableSchema,
344 payload: &[u8],
345 encryption: &EncryptionConfig,
346) -> Result<Row, String> {
347 let mut r = Reader::new(payload);
348 let mut row = decode_row(&mut r, &table.wire_columns).map_err(|e| e.to_string())?;
350 if !r.is_empty() {
351 return Err("row payload has trailing bytes".to_owned());
352 }
353 if table.has_encrypted_columns() {
354 decrypt_row(table, &mut row, encryption)?;
355 }
356 Ok(row)
357}
358
359pub fn decrypt_segment_row(
363 table: &TableSchema,
364 row: &mut Row,
365 encryption: &EncryptionConfig,
366) -> Result<(), String> {
367 decrypt_row(table, row, encryption)
368}
369
370#[cfg(feature = "e2ee")]
373fn encrypt_row(
374 table: &TableSchema,
375 _row_id: &str,
376 row: &mut Row,
377 encryption: &EncryptionConfig,
378) -> Result<(), String> {
379 use rand_core::RngCore;
380 use ssp2::crypto::{encrypt_value, NONCE_LENGTH};
381 for enc in &table.encrypted_columns {
382 let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
383 continue; };
385 let plain = column_value_to_plain(value)?;
386 let key_id = encryption.key_id_for(table, row)?;
387 let key = encryption
388 .keys
389 .get(&key_id)
390 .ok_or_else(|| format!("client.decrypt_failed: no key for keyId {key_id:?}"))?;
391 let mut nonce = [0u8; NONCE_LENGTH];
392 rand_core::OsRng.fill_bytes(&mut nonce);
393 let envelope = encrypt_value(&plain, &key_id, key, nonce)?;
394 row[enc.index] = Some(ColumnValue::Bytes(envelope));
395 }
396 Ok(())
397}
398
399#[cfg(feature = "e2ee")]
400fn decrypt_row(
401 table: &TableSchema,
402 row: &mut Row,
403 encryption: &EncryptionConfig,
404) -> Result<(), String> {
405 use ssp2::crypto::{decrypt_value, DeclaredType};
406 for enc in &table.encrypted_columns {
407 let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
408 continue;
409 };
410 let ColumnValue::Bytes(envelope) = value else {
411 return Err(format!(
412 "client.decrypt_failed: encrypted column at index {} is not bytes",
413 enc.index
414 ));
415 };
416 let declared = DeclaredType::from_name(&enc.declared_type)
417 .ok_or_else(|| format!("unknown declaredType {:?}", enc.declared_type))?;
418 let keys = &encryption.keys;
419 let plain = decrypt_value(declared, envelope, |id| keys.get(id).cloned())
420 .map_err(|e| e.to_string())?;
421 row[enc.index] = Some(plain_to_column_value(plain));
422 }
423 Ok(())
424}
425
426#[cfg(not(feature = "e2ee"))]
429fn encrypt_row(
430 table: &TableSchema,
431 _row_id: &str,
432 _row: &mut Row,
433 _encryption: &EncryptionConfig,
434) -> Result<(), String> {
435 Err(format!(
436 "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
437 table.name
438 ))
439}
440
441#[cfg(not(feature = "e2ee"))]
442fn decrypt_row(
443 table: &TableSchema,
444 _row: &mut Row,
445 _encryption: &EncryptionConfig,
446) -> Result<(), String> {
447 Err(format!(
448 "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
449 table.name
450 ))
451}
452
453#[cfg(feature = "e2ee")]
454fn column_value_to_plain(value: &ColumnValue) -> Result<ssp2::crypto::PlainValue, String> {
455 use ssp2::crypto::PlainValue;
456 Ok(match value {
457 ColumnValue::String(s) => PlainValue::String(s.clone()),
458 ColumnValue::Integer(i) => PlainValue::Integer(*i),
459 ColumnValue::Float(f) => PlainValue::Float(*f),
460 ColumnValue::Boolean(b) => PlainValue::Boolean(*b),
461 ColumnValue::Json(j) => PlainValue::Json(j.0.clone()),
462 ColumnValue::BlobRef(j) => PlainValue::BlobRef(j.0.clone()),
463 ColumnValue::Bytes(b) => PlainValue::Bytes(b.clone()),
464 ColumnValue::Crdt(_) => return Err("crdt columns cannot be encrypted (§5.11)".to_owned()),
465 })
466}
467
468#[cfg(feature = "e2ee")]
469fn plain_to_column_value(value: ssp2::crypto::PlainValue) -> ColumnValue {
470 use ssp2::crypto::PlainValue;
471 match value {
472 PlainValue::String(s) => ColumnValue::String(s),
473 PlainValue::Integer(i) => ColumnValue::Integer(i),
474 PlainValue::Float(f) => ColumnValue::Float(f),
475 PlainValue::Boolean(b) => ColumnValue::Boolean(b),
476 PlainValue::Json(s) => ColumnValue::Json(RawJson(s)),
477 PlainValue::BlobRef(s) => ColumnValue::BlobRef(RawJson(s)),
478 PlainValue::Bytes(b) => ColumnValue::Bytes(b),
479 }
480}
481
482pub fn render_row_id(value: &Option<ColumnValue>) -> Result<String, String> {
484 match value {
485 Some(ColumnValue::String(s)) => Ok(s.clone()),
486 Some(ColumnValue::Integer(i)) => Ok(i.to_string()),
487 Some(ColumnValue::Float(f)) => Ok(f.to_string()),
488 Some(ColumnValue::Boolean(b)) => Ok(b.to_string()),
489 Some(ColumnValue::Json(raw)) => Ok(raw.0.clone()),
490 Some(ColumnValue::BlobRef(_)) => Err("blob_ref column cannot be a rowId".to_owned()),
491 Some(ColumnValue::Bytes(_)) => Err("bytes column cannot be a rowId".to_owned()),
492 Some(ColumnValue::Crdt(_)) => Err("crdt column cannot be a rowId".to_owned()),
493 None => Err("primary key value is missing".to_owned()),
494 }
495}
496
497pub fn render_row_id_json(value: Option<&Value>) -> Result<String, String> {
499 match value {
500 Some(Value::String(s)) => Ok(s.clone()),
501 Some(Value::Number(n)) => Ok(n.to_string()),
502 Some(Value::Bool(b)) => Ok(b.to_string()),
503 _ => Err("primary key value is missing or not renderable".to_owned()),
504 }
505}
506
507fn sort_utf16(values: &mut [String]) {
508 values.sort_by(|a, b| {
509 if utf16_lt(a, b) {
510 std::cmp::Ordering::Less
511 } else if utf16_lt(b, a) {
512 std::cmp::Ordering::Greater
513 } else {
514 std::cmp::Ordering::Equal
515 }
516 });
517}
518
519pub fn sort_scope_map(map: &mut [(String, Vec<String>)]) {
522 map.sort_by(|a, b| {
523 if utf16_lt(&a.0, &b.0) {
524 std::cmp::Ordering::Less
525 } else if utf16_lt(&b.0, &a.0) {
526 std::cmp::Ordering::Greater
527 } else {
528 std::cmp::Ordering::Equal
529 }
530 });
531}
532
533pub fn canonical_scope_json(scopes: &[(String, Vec<String>)]) -> String {
536 let mut entries: Vec<(String, Vec<String>)> = scopes.to_vec();
537 sort_scope_map(&mut entries);
538 let mut out = String::from("{");
539 for (i, (key, values)) in entries.iter().enumerate() {
540 if i > 0 {
541 out.push(',');
542 }
543 let mut sorted = values.clone();
544 sort_utf16(&mut sorted);
545 sorted.dedup();
546 out.push_str(&serde_json::to_string(key).expect("string serializes"));
547 out.push_str(":[");
548 for (j, value) in sorted.iter().enumerate() {
549 if j > 0 {
550 out.push(',');
551 }
552 out.push_str(&serde_json::to_string(value).expect("string serializes"));
553 }
554 out.push(']');
555 }
556 out.push('}');
557 out
558}
559
560pub fn scope_map_to_json(scopes: &[(String, Vec<String>)]) -> Value {
562 let mut map = Map::new();
563 for (key, values) in scopes {
564 map.insert(
565 key.clone(),
566 Value::Array(values.iter().map(|v| Value::from(v.clone())).collect()),
567 );
568 }
569 Value::Object(map)
570}
571
572pub fn json_to_scope_map(value: &Value) -> Result<Vec<(String, Vec<String>)>, String> {
574 let object = value
575 .as_object()
576 .ok_or_else(|| "scope map must be an object".to_owned())?;
577 let mut out = Vec::with_capacity(object.len());
578 for (key, values) in object {
579 let list = values
580 .as_array()
581 .ok_or_else(|| format!("scope values for {key:?} must be a list (§0)"))?;
582 let mut strings = Vec::with_capacity(list.len());
583 for v in list {
584 strings.push(
585 v.as_str()
586 .ok_or_else(|| format!("scope value for {key:?} is not a string"))?
587 .to_owned(),
588 );
589 }
590 out.push((key.clone(), strings));
591 }
592 Ok(out)
593}
594
595#[cfg(test)]
596mod naming_tests {
597 use serde_json::{json, Map, Value};
598 use ssp2::segment::{Column, ColumnType, ColumnValue, Row};
599
600 use super::{
601 bytes_to_hex, hex_to_bytes, normalize_values_casing, snake_to_camel, EncryptionConfig,
602 };
603 use crate::schema::{EncryptedColumn, TableSchema};
604
605 #[test]
606 fn byte_envelopes_preserve_canonical_hex_for_every_byte() {
607 let bytes: Vec<u8> = (0..=255).collect();
608 let expected: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
609 assert_eq!(bytes_to_hex(&bytes), expected);
610 assert_eq!(hex_to_bytes(&expected).expect("decode"), bytes);
611 assert_eq!(bytes_to_hex(&[]), "");
612 assert_eq!(bytes_to_hex(&[0, 15, 16, 255]), "000f10ff");
613 let large = bytes.repeat(8192);
614 assert_eq!(bytes_to_hex(&large), expected.repeat(8192));
615 assert_eq!(
616 hex_to_bytes(&bytes_to_hex(&large)).expect("large decode"),
617 large
618 );
619 }
620
621 #[test]
622 fn byte_decoder_preserves_radix_parser_inputs_and_errors() {
623 for first in 0_u8..=127 {
624 for second in 0_u8..=127 {
625 let pair = [first, second];
626 let input = std::str::from_utf8(&pair).unwrap();
627 let expected = u8::from_str_radix(input, 16)
628 .map(|byte| vec![byte])
629 .map_err(|error| format!("bad hex: {error}"));
630 assert_eq!(hex_to_bytes(input), expected, "ASCII pair {pair:?}");
631 }
632 }
633 assert_eq!(hex_to_bytes("+fA0+100"), Ok(vec![15, 160, 1, 0]));
634 for input in [
635 "", "0", "abc", "é", "雪", "😀", "0é0", "é00", "00é", "00😀", "😀00", "\0\0",
636 ] {
637 let expected = if !input.len().is_multiple_of(2) {
638 Err("odd-length hex string".to_owned())
639 } else {
640 input
641 .as_bytes()
642 .chunks(2)
643 .map(|pair| {
644 let text =
645 std::str::from_utf8(pair).map_err(|_| "non-ASCII hex".to_owned())?;
646 u8::from_str_radix(text, 16).map_err(|error| format!("bad hex: {error}"))
647 })
648 .collect::<Result<Vec<_>, _>>()
649 };
650 assert_eq!(hex_to_bytes(input), expected, "input {input:?}");
651 }
652 }
653
654 #[test]
655 fn snake_to_camel_pinned_vectors() {
656 for (input, expected) in [
657 ("created_at", "createdAt"),
658 ("col_2", "col2"),
659 ("user_id", "userId"),
660 ("_internal", "_internal"),
661 ("__foo_bar", "__fooBar"),
662 ("row_", "row_"),
663 ("id_url", "idUrl"),
664 ("api_key", "apiKey"),
665 ("title", "title"),
666 ("alreadyCamel", "alreadyCamel"),
667 ("a__b", "aB"),
668 ("_lead_and_trail_", "_leadAndTrail_"),
669 ("count(*)", "count(*)"),
670 ] {
671 assert_eq!(snake_to_camel(input), expected, "input {input:?}");
672 }
673 }
674
675 #[test]
676 fn portable_key_selector_reads_a_non_encrypted_string_column() {
677 let columns = vec![
678 Column {
679 name: "id".to_owned(),
680 ty: ColumnType::String,
681 nullable: false,
682 },
683 Column {
684 name: "encryption_key_id".to_owned(),
685 ty: ColumnType::String,
686 nullable: false,
687 },
688 Column {
689 name: "note".to_owned(),
690 ty: ColumnType::String,
691 nullable: false,
692 },
693 ];
694 let table = TableSchema {
695 name: "patients".to_owned(),
696 columns: columns.clone(),
697 wire_columns: columns,
698 primary_key: "id".to_owned(),
699 pk_index: 0,
700 scope_variables: Vec::new(),
701 indexes: Vec::new(),
702 fts_indexes: Vec::new(),
703 encrypted_columns: vec![EncryptedColumn {
704 index: 2,
705 declared_type: "string".to_owned(),
706 }],
707 };
708 let mut config = EncryptionConfig::default();
709 config
710 .key_id_columns
711 .insert("patients".to_owned(), "encryption_key_id".to_owned());
712 let row: Row = vec![
713 Some(ColumnValue::String("patient-1".to_owned())),
714 Some(ColumnValue::String("practice-key-v1".to_owned())),
715 Some(ColumnValue::String("Identity".to_owned())),
716 ];
717 assert_eq!(
718 config.key_id_for(&table, &row).expect("selects key"),
719 "practice-key-v1"
720 );
721 }
722
723 #[test]
724 fn debug_render_redacts_key_material() {
725 let mut config = EncryptionConfig::default();
726 config
727 .keys
728 .insert("practice-key-v1".to_owned(), vec![0xAB; 32]);
729 config
730 .key_id_columns
731 .insert("patients".to_owned(), "encryption_key_id".to_owned());
732 let rendered = format!("{config:?}");
733 assert!(rendered.contains("practice-key-v1"), "{rendered}");
734 assert!(rendered.contains("<32 bytes>"), "{rendered}");
735 assert!(rendered.contains("encryption_key_id"), "{rendered}");
736 assert!(!rendered.contains("171"), "{rendered}");
738 assert!(!rendered.contains("0xAB"), "{rendered}");
739 }
740
741 fn table(names: &[&str]) -> TableSchema {
742 let columns: Vec<Column> = names
743 .iter()
744 .map(|n| Column {
745 name: (*n).to_owned(),
746 ty: ColumnType::String,
747 nullable: true,
748 })
749 .collect();
750 TableSchema {
751 name: "t".to_owned(),
752 columns: columns.clone(),
753 wire_columns: columns,
754 primary_key: "id".to_owned(),
755 pk_index: 0,
756 scope_variables: Vec::new(),
757 indexes: Vec::new(),
758 fts_indexes: Vec::new(),
759 encrypted_columns: Vec::new(),
760 }
761 }
762
763 fn map(entries: &[(&str, &str)]) -> Map<String, Value> {
764 entries
765 .iter()
766 .map(|(k, v)| ((*k).to_owned(), json!(v)))
767 .collect()
768 }
769
770 #[test]
771 fn camel_keys_normalize_to_sql_names() {
772 let t = table(&["id", "list_id", "updated_at_ms"]);
773 let out = normalize_values_casing(
774 &t,
775 map(&[("id", "x"), ("listId", "l"), ("updatedAtMs", "9")]),
776 )
777 .expect("normalizes");
778 assert_eq!(out.get("list_id"), Some(&json!("l")));
779 assert_eq!(out.get("updated_at_ms"), Some(&json!("9")));
780 assert!(!out.contains_key("listId"));
781 }
782
783 #[test]
784 fn snake_keys_pass_through() {
785 let t = table(&["id", "list_id"]);
786 let out = normalize_values_casing(&t, map(&[("id", "x"), ("list_id", "l")])).expect("ok");
787 assert_eq!(out.get("list_id"), Some(&json!("l")));
788 }
789
790 #[test]
791 fn both_casings_for_one_column_is_an_error() {
792 let t = table(&["id", "list_id"]);
793 let err = normalize_values_casing(&t, map(&[("list_id", "a"), ("listId", "b")]))
794 .expect_err("rejects");
795 assert!(err.contains("both snake_case and camelCase"), "{err}");
796 }
797
798 #[test]
799 fn an_alias_colliding_with_a_real_column_never_steals_it() {
800 let t = table(&["id", "col_2", "col2"]);
802 let out = normalize_values_casing(&t, map(&[("id", "x"), ("col2", "v")])).expect("ok");
803 assert_eq!(out.get("col2"), Some(&json!("v")));
804 assert!(!out.contains_key("col_2"));
805 }
806
807 #[test]
808 fn unknown_and_internal_columns_fail_loud() {
809 let t = table(&["id", "title"]);
810 let unknown = normalize_values_casing(&t, map(&[("id", "x"), ("typo", "v")]))
811 .expect_err("unknown field");
812 assert!(unknown.contains("unknown column"), "{unknown}");
813 let internal = normalize_values_casing(&t, map(&[("id", "x"), ("_sync_version", "1")]))
814 .expect_err("internal field");
815 assert!(internal.contains("internal sync column"), "{internal}");
816 }
817}