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};
13
14use crate::schema::TableSchema;
15
16#[derive(Clone, Default)]
21pub struct EncryptionConfig {
22 pub keys: HashMap<String, Vec<u8>>,
23 pub key_id_columns: HashMap<String, String>,
26}
27
28impl std::fmt::Debug for EncryptionConfig {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 struct Redacted(usize);
33 impl std::fmt::Debug for Redacted {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 write!(f, "<{} bytes>", self.0)
36 }
37 }
38 let keys: std::collections::BTreeMap<&String, Redacted> = self
39 .keys
40 .iter()
41 .map(|(id, key)| (id, Redacted(key.len())))
42 .collect();
43 f.debug_struct("EncryptionConfig")
44 .field("keys", &keys)
45 .field("key_id_columns", &self.key_id_columns)
46 .finish()
47 }
48}
49
50impl Drop for EncryptionConfig {
51 fn drop(&mut self) {
52 for key in self.keys.values_mut() {
55 key.fill(0);
56 }
57 }
58}
59
60impl EncryptionConfig {
61 pub fn is_empty(&self) -> bool {
62 self.keys.is_empty()
63 }
64
65 pub fn key_id_for(&self, table: &TableSchema, row: &Row) -> Result<String, String> {
70 let Some(column_name) = self.key_id_columns.get(&table.name) else {
71 return Ok(table.name.clone());
72 };
73 let Some(index) = table
74 .columns
75 .iter()
76 .position(|column| &column.name == column_name)
77 else {
78 return Err(format!(
79 "client.encrypt_failed: encryption key-id column {column_name:?} is not present on table {:?}",
80 table.name
81 ));
82 };
83 if table
84 .encrypted_columns
85 .iter()
86 .any(|column| column.index == index)
87 {
88 return Err(format!(
89 "client.encrypt_failed: encryption key-id column {column_name:?} on table {:?} must not be encrypted",
90 table.name
91 ));
92 }
93 match row.get(index).and_then(|value| value.as_ref()) {
94 Some(ColumnValue::String(key_id)) if !key_id.is_empty() => Ok(key_id.clone()),
95 _ => Err(format!(
96 "client.encrypt_failed: encryption key-id column {column_name:?} on table {:?} must contain a non-empty string",
97 table.name
98 )),
99 }
100 }
101}
102
103pub fn snake_to_camel(name: &str) -> String {
108 let is_mappable = {
109 let bare = name.trim_start_matches('_');
110 !bare.is_empty()
111 && bare.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
112 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
113 };
114 if !is_mappable {
115 return name.to_owned();
116 }
117 let lead_len = name.len() - name.trim_start_matches('_').len();
118 let (lead, bare) = name.split_at(lead_len);
119 let trail_len = bare.len() - bare.trim_end_matches('_').len();
120 let (middle, trail) = bare.split_at(bare.len() - trail_len);
121 let mut segments = middle.split('_').filter(|s| !s.is_empty());
122 let Some(first) = segments.next() else {
123 return name.to_owned();
124 };
125 let mut out = String::with_capacity(name.len());
126 out.push_str(lead);
127 out.push_str(first);
128 for segment in segments {
129 let mut chars = segment.chars();
130 if let Some(head) = chars.next() {
131 out.push(head.to_ascii_uppercase());
132 out.push_str(chars.as_str());
133 }
134 }
135 out.push_str(trail);
136 out
137}
138
139pub fn normalize_values_casing(
147 table: &TableSchema,
148 mut values: Map<String, Value>,
149) -> Result<Map<String, Value>, String> {
150 for column in &table.columns {
151 let camel = snake_to_camel(&column.name);
152 if camel == column.name {
153 continue;
154 }
155 if table.columns.iter().any(|c| c.name == camel) {
158 continue;
159 }
160 if table
161 .columns
162 .iter()
163 .filter(|c| snake_to_camel(&c.name) == camel)
164 .count()
165 > 1
166 {
167 continue;
168 }
169 if let Some(value) = values.remove(&camel) {
170 if values.contains_key(&column.name) {
171 return Err(format!(
172 "sync.invalid_request: table {:?}: column {:?} appears twice in mutation values (as both snake_case and camelCase) — pass it once",
173 table.name, column.name
174 ));
175 }
176 values.insert(column.name.clone(), value);
177 }
178 }
179 for key in values.keys() {
180 if !table.columns.iter().any(|column| column.name == *key) {
181 if key.starts_with("_sync_") {
182 return Err(format!(
183 "sync.invalid_request: table {:?}: {:?} is an internal sync column and cannot appear in mutation values",
184 table.name, key
185 ));
186 }
187 return Err(format!(
188 "sync.invalid_request: table {:?}: unknown column {:?} in mutation values (snake_case and camelCase keys are accepted)",
189 table.name, key
190 ));
191 }
192 }
193 Ok(values)
194}
195
196pub fn bytes_to_hex(bytes: &[u8]) -> String {
197 let mut out = String::with_capacity(bytes.len() * 2);
198 for b in bytes {
199 out.push(char::from(b"0123456789abcdef"[usize::from(b >> 4)]));
200 out.push(char::from(b"0123456789abcdef"[usize::from(b & 15)]));
201 }
202 out
203}
204
205pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
206 if !hex.len().is_multiple_of(2) {
207 return Err("odd-length hex string".to_owned());
208 }
209 let mut out = Vec::with_capacity(hex.len() / 2);
210 let nibble = |byte| match byte {
211 b'0'..=b'9' => byte - b'0',
212 b'a'..=b'f' => byte - b'a' + 10,
213 b'A'..=b'F' => byte - b'A' + 10,
214 _ => u8::MAX,
215 };
216 for pair in hex.as_bytes().as_chunks::<2>().0 {
217 let high = if pair[0] == b'+' { 0 } else { nibble(pair[0]) };
220 let low = nibble(pair[1]);
221 if high == u8::MAX || low == u8::MAX {
222 return Err(if std::str::from_utf8(pair).is_err() {
223 "non-ASCII hex".to_owned()
224 } else {
225 "bad hex: invalid digit found in string".to_owned()
226 });
227 }
228 out.push((high << 4) | low);
229 }
230 Ok(out)
231}
232
233pub fn json_to_column_value(
236 column: &Column,
237 value: Option<&Value>,
238) -> Result<Option<ColumnValue>, String> {
239 let value = match value {
240 None | Some(Value::Null) => return Ok(None),
241 Some(v) => v,
242 };
243 let fail = |expected: &str| {
244 Err(format!(
245 "column {:?}: expected {expected}, got {value}",
246 column.name
247 ))
248 };
249 match column.ty {
250 ColumnType::String => match value.as_str() {
251 Some(s) => Ok(Some(ColumnValue::String(s.to_owned()))),
252 None => fail("a string"),
253 },
254 ColumnType::Integer => match value.as_i64() {
255 Some(i)
256 if (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
257 .contains(&i) =>
258 {
259 Ok(Some(ColumnValue::Integer(i)))
260 }
261 _ => fail("an integer within the JavaScript safe-integer range"),
262 },
263 ColumnType::Float => match value.as_f64() {
264 Some(f) => Ok(Some(ColumnValue::Float(f))),
265 None => fail("a number"),
266 },
267 ColumnType::Boolean => match value.as_bool() {
268 Some(b) => Ok(Some(ColumnValue::Boolean(b))),
269 None => fail("a boolean"),
270 },
271 ColumnType::Json => match value.as_str() {
273 Some(s) => Ok(Some(ColumnValue::Json(RawJson(s.to_owned())))),
274 None => fail("a raw JSON string"),
275 },
276 ColumnType::BlobRef => match value.as_str() {
278 Some(s) => Ok(Some(ColumnValue::BlobRef(RawJson(s.to_owned())))),
279 None => fail("a raw BlobRef JSON string"),
280 },
281 ColumnType::Bytes => match value.get("$bytes").and_then(Value::as_str) {
282 Some(hex) => Ok(Some(ColumnValue::Bytes(hex_to_bytes(hex)?))),
283 None => fail("a {\"$bytes\": hex} object"),
284 },
285 ColumnType::Crdt => match value.get("$bytes").and_then(Value::as_str) {
287 Some(hex) => Ok(Some(ColumnValue::Crdt(hex_to_bytes(hex)?))),
288 None => fail("a {\"$bytes\": hex} object"),
289 },
290 }
291}
292
293pub fn column_value_to_json(value: &Option<ColumnValue>) -> Value {
295 match value {
296 None => Value::Null,
297 Some(ColumnValue::String(s)) => Value::from(s.clone()),
298 Some(ColumnValue::Integer(i)) => Value::from(*i),
299 Some(ColumnValue::Float(f)) => {
300 serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number)
301 }
302 Some(ColumnValue::Boolean(b)) => Value::from(*b),
303 Some(ColumnValue::Json(raw)) => Value::from(raw.0.clone()),
304 Some(ColumnValue::BlobRef(raw)) => Value::from(raw.0.clone()),
305 Some(ColumnValue::Bytes(bytes)) => {
306 let mut map = Map::new();
307 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
308 Value::Object(map)
309 }
310 Some(ColumnValue::Crdt(bytes)) => {
311 let mut map = Map::new();
312 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
313 Value::Object(map)
314 }
315 }
316}
317
318pub fn full_row_values(
322 table: &TableSchema,
323 mut values: Map<String, Value>,
324) -> Result<Map<String, Value>, String> {
325 for column in &table.columns {
326 if values.contains_key(&column.name) {
327 continue;
328 }
329 if !column.nullable {
330 return Err(format!(
331 "sync.invalid_request: table {:?}: column {:?} is not nullable (§6.1 full-row payloads)",
332 table.name, column.name
333 ));
334 }
335 values.insert(column.name.clone(), Value::Null);
336 }
337 Ok(values)
338}
339
340pub(crate) fn is_canonical_bytes_envelope(value: &Value) -> bool {
343 value
344 .as_object()
345 .filter(|map| map.len() == 1)
346 .and_then(|map| map.get("$bytes"))
347 .and_then(Value::as_str)
348 .is_some_and(|hex| {
349 hex.len().is_multiple_of(2) && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
350 })
351}
352
353fn author_bytes_envelope_error(column: &Column, value: &Value) -> Option<String> {
359 if value.is_null() || !matches!(column.ty, ColumnType::Bytes | ColumnType::Crdt) {
363 return None;
364 }
365 if is_canonical_bytes_envelope(value) {
366 return None;
367 }
368 Some(format!(
369 "sync.invalid_request: column {:?}: expected exactly {{\"$bytes\": hex}} with an even number of hexadecimal digits, got {value}",
370 column.name
371 ))
372}
373
374pub fn encode_row_json(
379 table: &TableSchema,
380 row_id: &str,
381 values: &Map<String, Value>,
382 encryption: &EncryptionConfig,
383) -> Result<Vec<u8>, String> {
384 let mut row: Row = Vec::with_capacity(table.columns.len());
386 for column in &table.columns {
387 if let Some(raw) = values.get(&column.name) {
388 if let Some(error) = author_bytes_envelope_error(column, raw) {
389 return Err(error);
390 }
391 }
392 let value = json_to_column_value(column, values.get(&column.name))
396 .map_err(|error| format!("sync.invalid_request: {error}"))?;
397 if value.is_none() && !column.nullable {
398 return Err(format!(
399 "sync.invalid_request: table {:?}: column {:?} is not nullable (§6.1 full-row payloads)",
400 table.name, column.name
401 ));
402 }
403 row.push(value);
404 }
405 if table.has_encrypted_columns() {
406 encrypt_row(table, row_id, &mut row, encryption)?;
407 }
408 let mut w = Writer::new();
410 encode_row(&mut w, &table.wire_columns, &row);
411 Ok(w.into_bytes())
412}
413
414pub fn encode_sparse_row_json(
420 table: &TableSchema,
421 row_id: &str,
422 values: &Map<String, Value>,
423 encryption: &EncryptionConfig,
424 fallback: Option<&Row>,
425) -> Result<Vec<u8>, String> {
426 let mut row: SparseRow = Vec::with_capacity(table.columns.len());
427 for column in &table.columns {
428 let Some(raw) = values.get(&column.name) else {
429 row.push(SparseSlot::Absent);
430 continue;
431 };
432 if let Some(error) = author_bytes_envelope_error(column, raw) {
433 return Err(error);
434 }
435 let refused = match (column.ty, raw) {
438 (ColumnType::String, Value::String(text)) => text.contains('\0'),
439 (ColumnType::Json, Value::String(text)) => {
440 ssp2::primitives::parse_json_document(text, "json value").is_err()
441 }
442 (ColumnType::BlobRef, Value::String(text)) => {
443 ssp2::blob_ref::validate_blob_ref(text).is_err()
444 }
445 _ => false,
446 };
447 if refused {
448 return Err(format!(
449 "sync.invalid_request: column {:?}: the value is not a push admission value (§2.4)",
450 column.name
451 ));
452 }
453 let value = json_to_column_value(column, Some(raw))
454 .map_err(|error| format!("sync.invalid_request: {error}"))?;
455 if value.is_none() && !column.nullable {
456 return Err(format!(
457 "sync.invalid_request: table {:?}: column {:?} is not nullable (§6.1)",
458 table.name, column.name
459 ));
460 }
461 row.push(value.map_or(SparseSlot::Null, SparseSlot::Value));
462 }
463 if table.has_encrypted_columns() {
464 encrypt_sparse_row(table, row_id, &mut row, encryption, fallback)?;
465 }
466 Ok(encode_sparse_row(&table.wire_columns, table.pk_index, &row))
467}
468
469pub fn decode_row_bytes(
473 table: &TableSchema,
474 payload: &[u8],
475 encryption: &EncryptionConfig,
476) -> Result<Row, String> {
477 let mut r = Reader::new(payload);
478 let mut row = decode_row(&mut r, &table.wire_columns).map_err(|e| e.to_string())?;
480 if !r.is_empty() {
481 return Err("row payload has trailing bytes".to_owned());
482 }
483 if table.has_encrypted_columns() {
484 decrypt_row(table, &mut row, encryption).map_err(|message| {
485 if message.starts_with("client.decrypt_failed") {
486 message
487 } else {
488 format!("client.decrypt_failed: {message}")
489 }
490 })?;
491 }
492 Ok(row)
493}
494
495pub fn decrypt_segment_row(
499 table: &TableSchema,
500 row: &mut Row,
501 encryption: &EncryptionConfig,
502) -> Result<(), String> {
503 decrypt_row(table, row, encryption)
504}
505
506#[cfg(feature = "e2ee")]
509fn encrypt_row(
510 table: &TableSchema,
511 _row_id: &str,
512 row: &mut Row,
513 encryption: &EncryptionConfig,
514) -> Result<(), String> {
515 use rand_core::RngCore;
516 use ssp2::crypto::{encrypt_value, NONCE_LENGTH};
517 for enc in &table.encrypted_columns {
518 let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
519 continue; };
521 let plain = column_value_to_plain(value)?;
522 let key_id = encryption.key_id_for(table, row)?;
523 let key = encryption
524 .keys
525 .get(&key_id)
526 .ok_or_else(|| format!("client.encrypt_failed: no key for keyId {key_id:?}"))?;
527 let mut nonce = [0u8; NONCE_LENGTH];
528 rand_core::OsRng.fill_bytes(&mut nonce);
529 let envelope = encrypt_value(&plain, &key_id, key, nonce)?;
530 row[enc.index] = Some(ColumnValue::Bytes(envelope));
531 }
532 Ok(())
533}
534
535#[cfg(feature = "e2ee")]
536fn encrypt_sparse_row(
537 table: &TableSchema,
538 _row_id: &str,
539 row: &mut SparseRow,
540 encryption: &EncryptionConfig,
541 fallback: Option<&Row>,
542) -> Result<(), String> {
543 use rand_core::RngCore;
544 use ssp2::crypto::{encrypt_value, NONCE_LENGTH};
545 let has_present_encrypted = table
548 .encrypted_columns
549 .iter()
550 .any(|enc| matches!(row.get(enc.index), Some(SparseSlot::Value(_))));
551 if !has_present_encrypted {
552 return Ok(());
553 }
554 let mut dense: Row = row
558 .iter()
559 .map(|slot| match slot {
560 SparseSlot::Value(value) => Some(value.clone()),
561 _ => None,
562 })
563 .collect();
564 if let Some(selector) = encryption.key_id_columns.get(&table.name) {
565 if let Some(index) = table
566 .columns
567 .iter()
568 .position(|column| &column.name == selector)
569 {
570 if matches!(row.get(index), None | Some(SparseSlot::Absent)) {
571 let stored: Option<&str> = match fallback
572 .and_then(|row| row.get(index))
573 .and_then(|value| value.as_ref())
574 {
575 Some(ColumnValue::String(key_id)) => Some(key_id),
576 _ => None,
577 };
578 if let Some(key_id) = stored.filter(|key_id| !key_id.is_empty()) {
579 dense[index] = Some(ColumnValue::String(key_id.to_owned()));
580 }
581 }
582 }
583 }
584 let key_id = encryption.key_id_for(table, &dense)?;
585 for enc in &table.encrypted_columns {
586 let Some(SparseSlot::Value(value)) = row.get(enc.index) else {
587 continue; };
589 let plain = column_value_to_plain(value)?;
590 let key = encryption
591 .keys
592 .get(&key_id)
593 .ok_or_else(|| format!("client.encrypt_failed: no key for keyId {key_id:?}"))?;
594 let mut nonce = [0u8; NONCE_LENGTH];
595 rand_core::OsRng.fill_bytes(&mut nonce);
596 let envelope = encrypt_value(&plain, &key_id, key, nonce)?;
597 row[enc.index] = SparseSlot::Value(ColumnValue::Bytes(envelope));
598 }
599 Ok(())
600}
601
602#[cfg(not(feature = "e2ee"))]
603fn encrypt_sparse_row(
604 table: &TableSchema,
605 _row_id: &str,
606 row: &mut SparseRow,
607 _encryption: &EncryptionConfig,
608 _fallback: Option<&Row>,
609) -> Result<(), String> {
610 if !table
611 .encrypted_columns
612 .iter()
613 .any(|enc| matches!(row.get(enc.index), Some(SparseSlot::Value(_))))
614 {
615 return Ok(());
616 }
617 Err(format!(
618 "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
619 table.name
620 ))
621}
622
623#[cfg(feature = "e2ee")]
624fn decrypt_row(
625 table: &TableSchema,
626 row: &mut Row,
627 encryption: &EncryptionConfig,
628) -> Result<(), String> {
629 use ssp2::crypto::{decrypt_value, DeclaredType};
630 for enc in &table.encrypted_columns {
631 let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
632 continue;
633 };
634 let ColumnValue::Bytes(envelope) = value else {
635 return Err(format!(
636 "client.decrypt_failed: encrypted column at index {} is not bytes",
637 enc.index
638 ));
639 };
640 let declared = DeclaredType::from_name(&enc.declared_type)
641 .ok_or_else(|| format!("unknown declaredType {:?}", enc.declared_type))?;
642 let keys = &encryption.keys;
643 let plain = decrypt_value(declared, envelope, |id| keys.get(id).cloned())
644 .map_err(|e| e.to_string())?;
645 row[enc.index] = Some(plain_to_column_value(plain));
646 }
647 Ok(())
648}
649
650#[cfg(not(feature = "e2ee"))]
653fn encrypt_row(
654 table: &TableSchema,
655 _row_id: &str,
656 _row: &mut Row,
657 _encryption: &EncryptionConfig,
658) -> Result<(), String> {
659 Err(format!(
660 "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
661 table.name
662 ))
663}
664
665#[cfg(not(feature = "e2ee"))]
666fn decrypt_row(
667 table: &TableSchema,
668 _row: &mut Row,
669 _encryption: &EncryptionConfig,
670) -> Result<(), String> {
671 Err(format!(
672 "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
673 table.name
674 ))
675}
676
677#[cfg(feature = "e2ee")]
678fn column_value_to_plain(value: &ColumnValue) -> Result<ssp2::crypto::PlainValue, String> {
679 use ssp2::crypto::PlainValue;
680 Ok(match value {
681 ColumnValue::String(s) => PlainValue::String(s.clone()),
682 ColumnValue::Integer(i) => PlainValue::Integer(*i),
683 ColumnValue::Float(f) => PlainValue::Float(*f),
684 ColumnValue::Boolean(b) => PlainValue::Boolean(*b),
685 ColumnValue::Json(j) => PlainValue::Json(j.0.clone()),
686 ColumnValue::BlobRef(j) => PlainValue::BlobRef(j.0.clone()),
687 ColumnValue::Bytes(b) => PlainValue::Bytes(b.clone()),
688 ColumnValue::Crdt(_) => return Err("crdt columns cannot be encrypted (§5.11)".to_owned()),
689 })
690}
691
692#[cfg(feature = "e2ee")]
693fn plain_to_column_value(value: ssp2::crypto::PlainValue) -> ColumnValue {
694 use ssp2::crypto::PlainValue;
695 match value {
696 PlainValue::String(s) => ColumnValue::String(s),
697 PlainValue::Integer(i) => ColumnValue::Integer(i),
698 PlainValue::Float(f) => ColumnValue::Float(f),
699 PlainValue::Boolean(b) => ColumnValue::Boolean(b),
700 PlainValue::Json(s) => ColumnValue::Json(RawJson(s)),
701 PlainValue::BlobRef(s) => ColumnValue::BlobRef(RawJson(s)),
702 PlainValue::Bytes(b) => ColumnValue::Bytes(b),
703 }
704}
705
706pub fn render_row_id(value: &Option<ColumnValue>) -> Result<String, String> {
708 match value {
709 Some(ColumnValue::String(s)) => Ok(s.clone()),
710 Some(ColumnValue::Integer(i)) => Ok(i.to_string()),
711 Some(ColumnValue::Float(f)) => Ok(f.to_string()),
712 Some(ColumnValue::Boolean(b)) => Ok(b.to_string()),
713 Some(ColumnValue::Json(raw)) => Ok(raw.0.clone()),
714 Some(ColumnValue::BlobRef(_)) => Err("blob_ref column cannot be a rowId".to_owned()),
715 Some(ColumnValue::Bytes(_)) => Err("bytes column cannot be a rowId".to_owned()),
716 Some(ColumnValue::Crdt(_)) => Err("crdt column cannot be a rowId".to_owned()),
717 None => Err("primary key value is missing".to_owned()),
718 }
719}
720
721pub fn render_row_id_json(value: Option<&Value>) -> Result<String, String> {
723 match value {
724 Some(Value::String(s)) => Ok(s.clone()),
725 Some(Value::Number(n)) => Ok(n.to_string()),
726 Some(Value::Bool(b)) => Ok(b.to_string()),
727 _ => Err("sync.invalid_request: primary key value is missing or not renderable".to_owned()),
728 }
729}
730
731pub fn scope_map_to_json(scopes: &[(String, Vec<String>)]) -> Value {
733 let mut map = Map::new();
734 for (key, values) in scopes {
735 map.insert(
736 key.clone(),
737 Value::Array(values.iter().map(|v| Value::from(v.clone())).collect()),
738 );
739 }
740 Value::Object(map)
741}
742
743pub fn json_to_scope_map(value: &Value) -> Result<Vec<(String, Vec<String>)>, String> {
745 let object = value
746 .as_object()
747 .ok_or_else(|| "scope map must be an object".to_owned())?;
748 let mut out = Vec::with_capacity(object.len());
749 for (key, values) in object {
750 let list = values
751 .as_array()
752 .ok_or_else(|| format!("scope values for {key:?} must be a list (§0)"))?;
753 let mut strings = Vec::with_capacity(list.len());
754 for v in list {
755 strings.push(
756 v.as_str()
757 .ok_or_else(|| format!("scope value for {key:?} is not a string"))?
758 .to_owned(),
759 );
760 }
761 out.push((key.clone(), strings));
762 }
763 Ok(out)
764}
765
766#[cfg(test)]
767mod naming_tests {
768 use serde_json::{json, Map, Value};
769 use ssp2::segment::{Column, ColumnType, ColumnValue, Row};
770
771 use super::{
772 bytes_to_hex, hex_to_bytes, normalize_values_casing, snake_to_camel, EncryptionConfig,
773 };
774 use crate::schema::{EncryptedColumn, TableSchema};
775
776 #[test]
777 fn authored_integer_cells_respect_the_wire_safe_range() {
778 let column = Column {
779 name: "n".into(),
780 ty: ColumnType::Integer,
781 nullable: false,
782 };
783 for value in [
784 9007199254740992_i64,
785 9007199254740993,
786 i64::MAX,
787 -9007199254740992,
788 i64::MIN,
789 ] {
790 assert!(super::json_to_column_value(&column, Some(&json!(value))).is_err());
791 }
792 for value in [-9007199254740991_i64, 0, 9007199254740991] {
793 assert!(super::json_to_column_value(&column, Some(&json!(value))).is_ok());
794 }
795 }
796
797 #[test]
798 fn byte_envelopes_preserve_canonical_hex_for_every_byte() {
799 let bytes: Vec<u8> = (0..=255).collect();
800 let expected: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
801 assert_eq!(bytes_to_hex(&bytes), expected);
802 assert_eq!(hex_to_bytes(&expected).expect("decode"), bytes);
803 assert_eq!(bytes_to_hex(&[]), "");
804 assert_eq!(bytes_to_hex(&[0, 15, 16, 255]), "000f10ff");
805 let large = bytes.repeat(8192);
806 assert_eq!(bytes_to_hex(&large), expected.repeat(8192));
807 assert_eq!(
808 hex_to_bytes(&bytes_to_hex(&large)).expect("large decode"),
809 large
810 );
811 }
812
813 #[test]
814 fn byte_decoder_preserves_radix_parser_inputs_and_errors() {
815 for first in 0_u8..=127 {
816 for second in 0_u8..=127 {
817 let pair = [first, second];
818 let input = std::str::from_utf8(&pair).unwrap();
819 let expected = u8::from_str_radix(input, 16)
820 .map(|byte| vec![byte])
821 .map_err(|error| format!("bad hex: {error}"));
822 assert_eq!(hex_to_bytes(input), expected, "ASCII pair {pair:?}");
823 }
824 }
825 assert_eq!(hex_to_bytes("+fA0+100"), Ok(vec![15, 160, 1, 0]));
826 for input in [
827 "", "0", "abc", "é", "雪", "😀", "0é0", "é00", "00é", "00😀", "😀00", "\0\0",
828 ] {
829 let expected = if !input.len().is_multiple_of(2) {
830 Err("odd-length hex string".to_owned())
831 } else {
832 input
833 .as_bytes()
834 .chunks(2)
835 .map(|pair| {
836 let text =
837 std::str::from_utf8(pair).map_err(|_| "non-ASCII hex".to_owned())?;
838 u8::from_str_radix(text, 16).map_err(|error| format!("bad hex: {error}"))
839 })
840 .collect::<Result<Vec<_>, _>>()
841 };
842 assert_eq!(hex_to_bytes(input), expected, "input {input:?}");
843 }
844 }
845
846 #[test]
847 fn snake_to_camel_pinned_vectors() {
848 for (input, expected) in [
849 ("created_at", "createdAt"),
850 ("col_2", "col2"),
851 ("user_id", "userId"),
852 ("_internal", "_internal"),
853 ("__foo_bar", "__fooBar"),
854 ("row_", "row_"),
855 ("id_url", "idUrl"),
856 ("api_key", "apiKey"),
857 ("title", "title"),
858 ("alreadyCamel", "alreadyCamel"),
859 ("a__b", "aB"),
860 ("_lead_and_trail_", "_leadAndTrail_"),
861 ("count(*)", "count(*)"),
862 ] {
863 assert_eq!(snake_to_camel(input), expected, "input {input:?}");
864 }
865 }
866
867 #[test]
868 fn portable_key_selector_reads_a_non_encrypted_string_column() {
869 let columns = vec![
870 Column {
871 name: "id".to_owned(),
872 ty: ColumnType::String,
873 nullable: false,
874 },
875 Column {
876 name: "encryption_key_id".to_owned(),
877 ty: ColumnType::String,
878 nullable: false,
879 },
880 Column {
881 name: "note".to_owned(),
882 ty: ColumnType::String,
883 nullable: false,
884 },
885 ];
886 let table = TableSchema {
887 name: "patients".to_owned(),
888 columns: columns.clone(),
889 wire_columns: columns,
890 primary_key: "id".to_owned(),
891 pk_index: 0,
892 scope_variables: Vec::new(),
893 indexes: Vec::new(),
894 fts_indexes: Vec::new(),
895 encrypted_columns: vec![EncryptedColumn {
896 index: 2,
897 declared_type: "string".to_owned(),
898 }],
899 };
900 let mut config = EncryptionConfig::default();
901 config
902 .key_id_columns
903 .insert("patients".to_owned(), "encryption_key_id".to_owned());
904 let row: Row = vec![
905 Some(ColumnValue::String("patient-1".to_owned())),
906 Some(ColumnValue::String("practice-key-v1".to_owned())),
907 Some(ColumnValue::String("Identity".to_owned())),
908 ];
909 assert_eq!(
910 config.key_id_for(&table, &row).expect("selects key"),
911 "practice-key-v1"
912 );
913 }
914
915 #[test]
916 fn debug_render_redacts_key_material() {
917 let mut config = EncryptionConfig::default();
918 config
919 .keys
920 .insert("practice-key-v1".to_owned(), vec![0xAB; 32]);
921 config
922 .key_id_columns
923 .insert("patients".to_owned(), "encryption_key_id".to_owned());
924 let rendered = format!("{config:?}");
925 assert!(rendered.contains("practice-key-v1"), "{rendered}");
926 assert!(rendered.contains("<32 bytes>"), "{rendered}");
927 assert!(rendered.contains("encryption_key_id"), "{rendered}");
928 assert!(!rendered.contains("171"), "{rendered}");
930 assert!(!rendered.contains("0xAB"), "{rendered}");
931 }
932
933 fn table(names: &[&str]) -> TableSchema {
934 let columns: Vec<Column> = names
935 .iter()
936 .map(|n| Column {
937 name: (*n).to_owned(),
938 ty: ColumnType::String,
939 nullable: true,
940 })
941 .collect();
942 TableSchema {
943 name: "t".to_owned(),
944 columns: columns.clone(),
945 wire_columns: columns,
946 primary_key: "id".to_owned(),
947 pk_index: 0,
948 scope_variables: Vec::new(),
949 indexes: Vec::new(),
950 fts_indexes: Vec::new(),
951 encrypted_columns: Vec::new(),
952 }
953 }
954
955 fn map(entries: &[(&str, &str)]) -> Map<String, Value> {
956 entries
957 .iter()
958 .map(|(k, v)| ((*k).to_owned(), json!(v)))
959 .collect()
960 }
961
962 #[test]
963 fn camel_keys_normalize_to_sql_names() {
964 let t = table(&["id", "list_id", "updated_at_ms"]);
965 let out = normalize_values_casing(
966 &t,
967 map(&[("id", "x"), ("listId", "l"), ("updatedAtMs", "9")]),
968 )
969 .expect("normalizes");
970 assert_eq!(out.get("list_id"), Some(&json!("l")));
971 assert_eq!(out.get("updated_at_ms"), Some(&json!("9")));
972 assert!(!out.contains_key("listId"));
973 }
974
975 #[test]
976 fn snake_keys_pass_through() {
977 let t = table(&["id", "list_id"]);
978 let out = normalize_values_casing(&t, map(&[("id", "x"), ("list_id", "l")])).expect("ok");
979 assert_eq!(out.get("list_id"), Some(&json!("l")));
980 }
981
982 #[test]
983 fn both_casings_for_one_column_is_an_error() {
984 let t = table(&["id", "list_id"]);
985 let err = normalize_values_casing(&t, map(&[("list_id", "a"), ("listId", "b")]))
986 .expect_err("rejects");
987 assert!(err.contains("both snake_case and camelCase"), "{err}");
988 }
989
990 #[test]
991 fn an_alias_colliding_with_a_real_column_never_steals_it() {
992 let t = table(&["id", "col_2", "col2"]);
994 let out = normalize_values_casing(&t, map(&[("id", "x"), ("col2", "v")])).expect("ok");
995 assert_eq!(out.get("col2"), Some(&json!("v")));
996 assert!(!out.contains_key("col_2"));
997 }
998
999 #[test]
1000 fn unknown_and_internal_columns_fail_loud() {
1001 let t = table(&["id", "title"]);
1002 let unknown = normalize_values_casing(&t, map(&[("id", "x"), ("typo", "v")]))
1003 .expect_err("unknown field");
1004 assert!(unknown.contains("unknown column"), "{unknown}");
1005 let internal = normalize_values_casing(&t, map(&[("id", "x"), ("_sync_version", "1")]))
1006 .expect_err("internal field");
1007 assert!(internal.contains("internal sync column"), "{internal}");
1008 }
1009}
1010
1011#[cfg(test)]
1012mod sparse_key_tests {
1013 use ssp2::segment::{Column, ColumnType, ColumnValue, Row};
1014
1015 use super::EncryptionConfig;
1016 use crate::schema::{EncryptedColumn, TableSchema};
1017
1018 #[cfg(feature = "e2ee")]
1019 use super::encode_sparse_row_json;
1020 #[cfg(feature = "e2ee")]
1021 use serde_json::{json, Map, Value};
1022
1023 fn keyed_table() -> TableSchema {
1024 let columns = vec![
1025 Column {
1026 name: "id".to_owned(),
1027 ty: ColumnType::String,
1028 nullable: false,
1029 },
1030 Column {
1031 name: "encryption_key_id".to_owned(),
1032 ty: ColumnType::String,
1033 nullable: true,
1034 },
1035 Column {
1036 name: "note".to_owned(),
1037 ty: ColumnType::String,
1038 nullable: true,
1039 },
1040 ];
1041 let wire_columns = vec![
1042 Column {
1043 name: "id".to_owned(),
1044 ty: ColumnType::String,
1045 nullable: false,
1046 },
1047 Column {
1048 name: "encryption_key_id".to_owned(),
1049 ty: ColumnType::String,
1050 nullable: true,
1051 },
1052 Column {
1053 name: "note".to_owned(),
1054 ty: ColumnType::Bytes,
1055 nullable: true,
1056 },
1057 ];
1058 TableSchema {
1059 name: "t".to_owned(),
1060 columns,
1061 wire_columns,
1062 primary_key: "id".to_owned(),
1063 pk_index: 0,
1064 scope_variables: Vec::new(),
1065 indexes: Vec::new(),
1066 fts_indexes: Vec::new(),
1067 encrypted_columns: vec![EncryptedColumn {
1068 index: 2,
1069 declared_type: "string".to_owned(),
1070 }],
1071 }
1072 }
1073
1074 fn keyed_config() -> EncryptionConfig {
1075 let mut config = EncryptionConfig::default();
1076 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
1077 config
1078 .key_id_columns
1079 .insert("t".to_owned(), "encryption_key_id".to_owned());
1080 config
1081 }
1082
1083 #[cfg(feature = "e2ee")]
1084 fn values(entries: &[(&str, Value)]) -> Map<String, Value> {
1085 entries
1086 .iter()
1087 .map(|(key, value)| ((*key).to_owned(), value.clone()))
1088 .collect()
1089 }
1090
1091 #[test]
1092 fn key_selection_failures_carry_the_encode_seam_code() {
1093 let table = keyed_table();
1094 let config = keyed_config();
1095 let row: Row = vec![
1097 Some(ColumnValue::String("r1".to_owned())),
1098 None,
1099 Some(ColumnValue::String("hi".to_owned())),
1100 ];
1101 let error = config.key_id_for(&table, &row).expect_err("rejects");
1102 assert!(error.starts_with("client.encrypt_failed"), "{error}");
1103 assert!(!error.starts_with("client.decrypt_failed"), "{error}");
1104 }
1105
1106 #[cfg(feature = "e2ee")]
1107 #[test]
1108 fn sparse_patch_without_an_encrypted_slot_needs_no_key() {
1109 let table = keyed_table();
1110 let config = EncryptionConfig::default();
1112 let payload =
1113 encode_sparse_row_json(&table, "r1", &values(&[("id", json!("r1"))]), &config, None)
1114 .expect("encodes without key resolution");
1115 assert!(!payload.is_empty());
1116 }
1117
1118 #[cfg(feature = "e2ee")]
1119 #[test]
1120 fn sparse_patch_falls_back_to_the_stored_key_id() {
1121 let table = keyed_table();
1122 let config = keyed_config();
1123 let sparse = values(&[("id", json!("r1")), ("note", json!("hi"))]);
1125 let error = encode_sparse_row_json(&table, "r1", &sparse, &config, None)
1126 .expect_err("absent selector with no fallback fails");
1127 assert!(error.contains("client.encrypt_failed"), "{error}");
1128 let fallback: Row = vec![
1129 Some(ColumnValue::String("r1".to_owned())),
1130 Some(ColumnValue::String("k1".to_owned())),
1131 None,
1132 ];
1133 encode_sparse_row_json(&table, "r1", &sparse, &config, Some(&fallback))
1134 .expect("stored key id encrypts the patch");
1135 }
1136
1137 #[cfg(feature = "e2ee")]
1138 #[test]
1139 fn sparse_patch_keeps_a_present_null_selector() {
1140 let table = keyed_table();
1141 let config = keyed_config();
1142 let sparse = values(&[
1146 ("id", json!("r1")),
1147 ("encryption_key_id", json!(null)),
1148 ("note", json!("hi")),
1149 ]);
1150 let fallback: Row = vec![
1151 Some(ColumnValue::String("r1".to_owned())),
1152 Some(ColumnValue::String("k1".to_owned())),
1153 None,
1154 ];
1155 let error = encode_sparse_row_json(&table, "r1", &sparse, &config, Some(&fallback))
1156 .expect_err("a present NULL selector must not read the stored fallback");
1157 assert!(error.contains("client.encrypt_failed"), "{error}");
1158 }
1159}