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