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