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 "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 "table {:?}: {:?} is an internal sync column and cannot appear in mutation values",
185 table.name, key
186 ));
187 }
188 return Err(format!(
189 "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) => Ok(Some(ColumnValue::Integer(i))),
257 None => fail("an integer"),
258 },
259 ColumnType::Float => match value.as_f64() {
260 Some(f) => Ok(Some(ColumnValue::Float(f))),
261 None => fail("a number"),
262 },
263 ColumnType::Boolean => match value.as_bool() {
264 Some(b) => Ok(Some(ColumnValue::Boolean(b))),
265 None => fail("a boolean"),
266 },
267 ColumnType::Json => match value.as_str() {
269 Some(s) => Ok(Some(ColumnValue::Json(RawJson(s.to_owned())))),
270 None => fail("a raw JSON string"),
271 },
272 ColumnType::BlobRef => match value.as_str() {
274 Some(s) => Ok(Some(ColumnValue::BlobRef(RawJson(s.to_owned())))),
275 None => fail("a raw BlobRef JSON string"),
276 },
277 ColumnType::Bytes => match value.get("$bytes").and_then(Value::as_str) {
278 Some(hex) => Ok(Some(ColumnValue::Bytes(hex_to_bytes(hex)?))),
279 None => fail("a {\"$bytes\": hex} object"),
280 },
281 ColumnType::Crdt => match value.get("$bytes").and_then(Value::as_str) {
283 Some(hex) => Ok(Some(ColumnValue::Crdt(hex_to_bytes(hex)?))),
284 None => fail("a {\"$bytes\": hex} object"),
285 },
286 }
287}
288
289pub fn column_value_to_json(value: &Option<ColumnValue>) -> Value {
291 match value {
292 None => Value::Null,
293 Some(ColumnValue::String(s)) => Value::from(s.clone()),
294 Some(ColumnValue::Integer(i)) => Value::from(*i),
295 Some(ColumnValue::Float(f)) => {
296 serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number)
297 }
298 Some(ColumnValue::Boolean(b)) => Value::from(*b),
299 Some(ColumnValue::Json(raw)) => Value::from(raw.0.clone()),
300 Some(ColumnValue::BlobRef(raw)) => Value::from(raw.0.clone()),
301 Some(ColumnValue::Bytes(bytes)) => {
302 let mut map = Map::new();
303 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
304 Value::Object(map)
305 }
306 Some(ColumnValue::Crdt(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 }
312}
313
314pub fn full_row_values(
318 table: &TableSchema,
319 mut values: Map<String, Value>,
320) -> Result<Map<String, Value>, String> {
321 for column in &table.columns {
322 if values.contains_key(&column.name) {
323 continue;
324 }
325 if !column.nullable {
326 return Err(format!(
327 "table {:?}: column {:?} is not nullable (§6.1 full-row payloads)",
328 table.name, column.name
329 ));
330 }
331 values.insert(column.name.clone(), Value::Null);
332 }
333 Ok(values)
334}
335
336pub fn encode_row_json(
341 table: &TableSchema,
342 row_id: &str,
343 values: &Map<String, Value>,
344 encryption: &EncryptionConfig,
345) -> Result<Vec<u8>, String> {
346 let mut row: Row = Vec::with_capacity(table.columns.len());
348 for column in &table.columns {
349 let value = json_to_column_value(column, values.get(&column.name))?;
350 if value.is_none() && !column.nullable {
351 return Err(format!(
352 "table {:?}: column {:?} is not nullable (§6.1 full-row payloads)",
353 table.name, column.name
354 ));
355 }
356 row.push(value);
357 }
358 if table.has_encrypted_columns() {
359 encrypt_row(table, row_id, &mut row, encryption)?;
360 }
361 let mut w = Writer::new();
363 encode_row(&mut w, &table.wire_columns, &row);
364 Ok(w.into_bytes())
365}
366
367pub fn encode_sparse_row_json(
373 table: &TableSchema,
374 row_id: &str,
375 values: &Map<String, Value>,
376 encryption: &EncryptionConfig,
377 fallback: Option<&Row>,
378) -> Result<Vec<u8>, String> {
379 let mut row: SparseRow = Vec::with_capacity(table.columns.len());
380 for column in &table.columns {
381 let Some(raw) = values.get(&column.name) else {
382 row.push(SparseSlot::Absent);
383 continue;
384 };
385 let value = json_to_column_value(column, Some(raw))?;
386 if value.is_none() && !column.nullable {
387 return Err(format!(
388 "table {:?}: column {:?} is not nullable (§6.1)",
389 table.name, column.name
390 ));
391 }
392 row.push(value.map_or(SparseSlot::Null, SparseSlot::Value));
393 }
394 if table.has_encrypted_columns() {
395 encrypt_sparse_row(table, row_id, &mut row, encryption, fallback)?;
396 }
397 Ok(encode_sparse_row(&table.wire_columns, table.pk_index, &row))
398}
399
400pub fn decode_row_bytes(
404 table: &TableSchema,
405 payload: &[u8],
406 encryption: &EncryptionConfig,
407) -> Result<Row, String> {
408 let mut r = Reader::new(payload);
409 let mut row = decode_row(&mut r, &table.wire_columns).map_err(|e| e.to_string())?;
411 if !r.is_empty() {
412 return Err("row payload has trailing bytes".to_owned());
413 }
414 if table.has_encrypted_columns() {
415 decrypt_row(table, &mut row, encryption)?;
416 }
417 Ok(row)
418}
419
420pub fn decrypt_segment_row(
424 table: &TableSchema,
425 row: &mut Row,
426 encryption: &EncryptionConfig,
427) -> Result<(), String> {
428 decrypt_row(table, row, encryption)
429}
430
431#[cfg(feature = "e2ee")]
434fn encrypt_row(
435 table: &TableSchema,
436 _row_id: &str,
437 row: &mut Row,
438 encryption: &EncryptionConfig,
439) -> Result<(), String> {
440 use rand_core::RngCore;
441 use ssp2::crypto::{encrypt_value, NONCE_LENGTH};
442 for enc in &table.encrypted_columns {
443 let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
444 continue; };
446 let plain = column_value_to_plain(value)?;
447 let key_id = encryption.key_id_for(table, row)?;
448 let key = encryption
449 .keys
450 .get(&key_id)
451 .ok_or_else(|| format!("client.encrypt_failed: no key for keyId {key_id:?}"))?;
452 let mut nonce = [0u8; NONCE_LENGTH];
453 rand_core::OsRng.fill_bytes(&mut nonce);
454 let envelope = encrypt_value(&plain, &key_id, key, nonce)?;
455 row[enc.index] = Some(ColumnValue::Bytes(envelope));
456 }
457 Ok(())
458}
459
460#[cfg(feature = "e2ee")]
461fn encrypt_sparse_row(
462 table: &TableSchema,
463 _row_id: &str,
464 row: &mut SparseRow,
465 encryption: &EncryptionConfig,
466 fallback: Option<&Row>,
467) -> Result<(), String> {
468 use rand_core::RngCore;
469 use ssp2::crypto::{encrypt_value, NONCE_LENGTH};
470 let has_present_encrypted = table
473 .encrypted_columns
474 .iter()
475 .any(|enc| matches!(row.get(enc.index), Some(SparseSlot::Value(_))));
476 if !has_present_encrypted {
477 return Ok(());
478 }
479 let mut dense: Row = row
483 .iter()
484 .map(|slot| match slot {
485 SparseSlot::Value(value) => Some(value.clone()),
486 _ => None,
487 })
488 .collect();
489 if let Some(selector) = encryption.key_id_columns.get(&table.name) {
490 if let Some(index) = table
491 .columns
492 .iter()
493 .position(|column| &column.name == selector)
494 {
495 if matches!(row.get(index), None | Some(SparseSlot::Absent)) {
496 let stored: Option<&str> = match fallback
497 .and_then(|row| row.get(index))
498 .and_then(|value| value.as_ref())
499 {
500 Some(ColumnValue::String(key_id)) => Some(key_id),
501 _ => None,
502 };
503 if let Some(key_id) = stored.filter(|key_id| !key_id.is_empty()) {
504 dense[index] = Some(ColumnValue::String(key_id.to_owned()));
505 }
506 }
507 }
508 }
509 let key_id = encryption.key_id_for(table, &dense)?;
510 for enc in &table.encrypted_columns {
511 let Some(SparseSlot::Value(value)) = row.get(enc.index) else {
512 continue; };
514 let plain = column_value_to_plain(value)?;
515 let key = encryption
516 .keys
517 .get(&key_id)
518 .ok_or_else(|| format!("client.encrypt_failed: no key for keyId {key_id:?}"))?;
519 let mut nonce = [0u8; NONCE_LENGTH];
520 rand_core::OsRng.fill_bytes(&mut nonce);
521 let envelope = encrypt_value(&plain, &key_id, key, nonce)?;
522 row[enc.index] = SparseSlot::Value(ColumnValue::Bytes(envelope));
523 }
524 Ok(())
525}
526
527#[cfg(not(feature = "e2ee"))]
528fn encrypt_sparse_row(
529 table: &TableSchema,
530 _row_id: &str,
531 row: &mut SparseRow,
532 _encryption: &EncryptionConfig,
533 _fallback: Option<&Row>,
534) -> Result<(), String> {
535 if !table
536 .encrypted_columns
537 .iter()
538 .any(|enc| matches!(row.get(enc.index), Some(SparseSlot::Value(_))))
539 {
540 return Ok(());
541 }
542 Err(format!(
543 "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
544 table.name
545 ))
546}
547
548#[cfg(feature = "e2ee")]
549fn decrypt_row(
550 table: &TableSchema,
551 row: &mut Row,
552 encryption: &EncryptionConfig,
553) -> Result<(), String> {
554 use ssp2::crypto::{decrypt_value, DeclaredType};
555 for enc in &table.encrypted_columns {
556 let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
557 continue;
558 };
559 let ColumnValue::Bytes(envelope) = value else {
560 return Err(format!(
561 "client.decrypt_failed: encrypted column at index {} is not bytes",
562 enc.index
563 ));
564 };
565 let declared = DeclaredType::from_name(&enc.declared_type)
566 .ok_or_else(|| format!("unknown declaredType {:?}", enc.declared_type))?;
567 let keys = &encryption.keys;
568 let plain = decrypt_value(declared, envelope, |id| keys.get(id).cloned())
569 .map_err(|e| e.to_string())?;
570 row[enc.index] = Some(plain_to_column_value(plain));
571 }
572 Ok(())
573}
574
575#[cfg(not(feature = "e2ee"))]
578fn encrypt_row(
579 table: &TableSchema,
580 _row_id: &str,
581 _row: &mut Row,
582 _encryption: &EncryptionConfig,
583) -> Result<(), String> {
584 Err(format!(
585 "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
586 table.name
587 ))
588}
589
590#[cfg(not(feature = "e2ee"))]
591fn decrypt_row(
592 table: &TableSchema,
593 _row: &mut Row,
594 _encryption: &EncryptionConfig,
595) -> Result<(), String> {
596 Err(format!(
597 "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
598 table.name
599 ))
600}
601
602#[cfg(feature = "e2ee")]
603fn column_value_to_plain(value: &ColumnValue) -> Result<ssp2::crypto::PlainValue, String> {
604 use ssp2::crypto::PlainValue;
605 Ok(match value {
606 ColumnValue::String(s) => PlainValue::String(s.clone()),
607 ColumnValue::Integer(i) => PlainValue::Integer(*i),
608 ColumnValue::Float(f) => PlainValue::Float(*f),
609 ColumnValue::Boolean(b) => PlainValue::Boolean(*b),
610 ColumnValue::Json(j) => PlainValue::Json(j.0.clone()),
611 ColumnValue::BlobRef(j) => PlainValue::BlobRef(j.0.clone()),
612 ColumnValue::Bytes(b) => PlainValue::Bytes(b.clone()),
613 ColumnValue::Crdt(_) => return Err("crdt columns cannot be encrypted (§5.11)".to_owned()),
614 })
615}
616
617#[cfg(feature = "e2ee")]
618fn plain_to_column_value(value: ssp2::crypto::PlainValue) -> ColumnValue {
619 use ssp2::crypto::PlainValue;
620 match value {
621 PlainValue::String(s) => ColumnValue::String(s),
622 PlainValue::Integer(i) => ColumnValue::Integer(i),
623 PlainValue::Float(f) => ColumnValue::Float(f),
624 PlainValue::Boolean(b) => ColumnValue::Boolean(b),
625 PlainValue::Json(s) => ColumnValue::Json(RawJson(s)),
626 PlainValue::BlobRef(s) => ColumnValue::BlobRef(RawJson(s)),
627 PlainValue::Bytes(b) => ColumnValue::Bytes(b),
628 }
629}
630
631pub fn render_row_id(value: &Option<ColumnValue>) -> Result<String, String> {
633 match value {
634 Some(ColumnValue::String(s)) => Ok(s.clone()),
635 Some(ColumnValue::Integer(i)) => Ok(i.to_string()),
636 Some(ColumnValue::Float(f)) => Ok(f.to_string()),
637 Some(ColumnValue::Boolean(b)) => Ok(b.to_string()),
638 Some(ColumnValue::Json(raw)) => Ok(raw.0.clone()),
639 Some(ColumnValue::BlobRef(_)) => Err("blob_ref column cannot be a rowId".to_owned()),
640 Some(ColumnValue::Bytes(_)) => Err("bytes column cannot be a rowId".to_owned()),
641 Some(ColumnValue::Crdt(_)) => Err("crdt column cannot be a rowId".to_owned()),
642 None => Err("primary key value is missing".to_owned()),
643 }
644}
645
646pub fn render_row_id_json(value: Option<&Value>) -> Result<String, String> {
648 match value {
649 Some(Value::String(s)) => Ok(s.clone()),
650 Some(Value::Number(n)) => Ok(n.to_string()),
651 Some(Value::Bool(b)) => Ok(b.to_string()),
652 _ => Err("primary key value is missing or not renderable".to_owned()),
653 }
654}
655
656fn sort_utf16(values: &mut [String]) {
657 values.sort_by(|a, b| {
658 if utf16_lt(a, b) {
659 std::cmp::Ordering::Less
660 } else if utf16_lt(b, a) {
661 std::cmp::Ordering::Greater
662 } else {
663 std::cmp::Ordering::Equal
664 }
665 });
666}
667
668pub fn sort_scope_map(map: &mut [(String, Vec<String>)]) {
671 map.sort_by(|a, b| {
672 if utf16_lt(&a.0, &b.0) {
673 std::cmp::Ordering::Less
674 } else if utf16_lt(&b.0, &a.0) {
675 std::cmp::Ordering::Greater
676 } else {
677 std::cmp::Ordering::Equal
678 }
679 });
680}
681
682pub fn canonical_scope_json(scopes: &[(String, Vec<String>)]) -> String {
685 let mut entries: Vec<(String, Vec<String>)> = scopes.to_vec();
686 sort_scope_map(&mut entries);
687 let mut out = String::from("{");
688 for (i, (key, values)) in entries.iter().enumerate() {
689 if i > 0 {
690 out.push(',');
691 }
692 let mut sorted = values.clone();
693 sort_utf16(&mut sorted);
694 sorted.dedup();
695 out.push_str(&serde_json::to_string(key).expect("string serializes"));
696 out.push_str(":[");
697 for (j, value) in sorted.iter().enumerate() {
698 if j > 0 {
699 out.push(',');
700 }
701 out.push_str(&serde_json::to_string(value).expect("string serializes"));
702 }
703 out.push(']');
704 }
705 out.push('}');
706 out
707}
708
709pub fn scope_map_to_json(scopes: &[(String, Vec<String>)]) -> Value {
711 let mut map = Map::new();
712 for (key, values) in scopes {
713 map.insert(
714 key.clone(),
715 Value::Array(values.iter().map(|v| Value::from(v.clone())).collect()),
716 );
717 }
718 Value::Object(map)
719}
720
721pub fn json_to_scope_map(value: &Value) -> Result<Vec<(String, Vec<String>)>, String> {
723 let object = value
724 .as_object()
725 .ok_or_else(|| "scope map must be an object".to_owned())?;
726 let mut out = Vec::with_capacity(object.len());
727 for (key, values) in object {
728 let list = values
729 .as_array()
730 .ok_or_else(|| format!("scope values for {key:?} must be a list (§0)"))?;
731 let mut strings = Vec::with_capacity(list.len());
732 for v in list {
733 strings.push(
734 v.as_str()
735 .ok_or_else(|| format!("scope value for {key:?} is not a string"))?
736 .to_owned(),
737 );
738 }
739 out.push((key.clone(), strings));
740 }
741 Ok(out)
742}
743
744#[cfg(test)]
745mod naming_tests {
746 use serde_json::{json, Map, Value};
747 use ssp2::segment::{Column, ColumnType, ColumnValue, Row};
748
749 use super::{
750 bytes_to_hex, hex_to_bytes, normalize_values_casing, snake_to_camel, EncryptionConfig,
751 };
752 use crate::schema::{EncryptedColumn, TableSchema};
753
754 #[test]
755 fn byte_envelopes_preserve_canonical_hex_for_every_byte() {
756 let bytes: Vec<u8> = (0..=255).collect();
757 let expected: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
758 assert_eq!(bytes_to_hex(&bytes), expected);
759 assert_eq!(hex_to_bytes(&expected).expect("decode"), bytes);
760 assert_eq!(bytes_to_hex(&[]), "");
761 assert_eq!(bytes_to_hex(&[0, 15, 16, 255]), "000f10ff");
762 let large = bytes.repeat(8192);
763 assert_eq!(bytes_to_hex(&large), expected.repeat(8192));
764 assert_eq!(
765 hex_to_bytes(&bytes_to_hex(&large)).expect("large decode"),
766 large
767 );
768 }
769
770 #[test]
771 fn byte_decoder_preserves_radix_parser_inputs_and_errors() {
772 for first in 0_u8..=127 {
773 for second in 0_u8..=127 {
774 let pair = [first, second];
775 let input = std::str::from_utf8(&pair).unwrap();
776 let expected = u8::from_str_radix(input, 16)
777 .map(|byte| vec![byte])
778 .map_err(|error| format!("bad hex: {error}"));
779 assert_eq!(hex_to_bytes(input), expected, "ASCII pair {pair:?}");
780 }
781 }
782 assert_eq!(hex_to_bytes("+fA0+100"), Ok(vec![15, 160, 1, 0]));
783 for input in [
784 "", "0", "abc", "é", "雪", "😀", "0é0", "é00", "00é", "00😀", "😀00", "\0\0",
785 ] {
786 let expected = if !input.len().is_multiple_of(2) {
787 Err("odd-length hex string".to_owned())
788 } else {
789 input
790 .as_bytes()
791 .chunks(2)
792 .map(|pair| {
793 let text =
794 std::str::from_utf8(pair).map_err(|_| "non-ASCII hex".to_owned())?;
795 u8::from_str_radix(text, 16).map_err(|error| format!("bad hex: {error}"))
796 })
797 .collect::<Result<Vec<_>, _>>()
798 };
799 assert_eq!(hex_to_bytes(input), expected, "input {input:?}");
800 }
801 }
802
803 #[test]
804 fn snake_to_camel_pinned_vectors() {
805 for (input, expected) in [
806 ("created_at", "createdAt"),
807 ("col_2", "col2"),
808 ("user_id", "userId"),
809 ("_internal", "_internal"),
810 ("__foo_bar", "__fooBar"),
811 ("row_", "row_"),
812 ("id_url", "idUrl"),
813 ("api_key", "apiKey"),
814 ("title", "title"),
815 ("alreadyCamel", "alreadyCamel"),
816 ("a__b", "aB"),
817 ("_lead_and_trail_", "_leadAndTrail_"),
818 ("count(*)", "count(*)"),
819 ] {
820 assert_eq!(snake_to_camel(input), expected, "input {input:?}");
821 }
822 }
823
824 #[test]
825 fn portable_key_selector_reads_a_non_encrypted_string_column() {
826 let columns = vec![
827 Column {
828 name: "id".to_owned(),
829 ty: ColumnType::String,
830 nullable: false,
831 },
832 Column {
833 name: "encryption_key_id".to_owned(),
834 ty: ColumnType::String,
835 nullable: false,
836 },
837 Column {
838 name: "note".to_owned(),
839 ty: ColumnType::String,
840 nullable: false,
841 },
842 ];
843 let table = TableSchema {
844 name: "patients".to_owned(),
845 columns: columns.clone(),
846 wire_columns: columns,
847 primary_key: "id".to_owned(),
848 pk_index: 0,
849 scope_variables: Vec::new(),
850 indexes: Vec::new(),
851 fts_indexes: Vec::new(),
852 encrypted_columns: vec![EncryptedColumn {
853 index: 2,
854 declared_type: "string".to_owned(),
855 }],
856 };
857 let mut config = EncryptionConfig::default();
858 config
859 .key_id_columns
860 .insert("patients".to_owned(), "encryption_key_id".to_owned());
861 let row: Row = vec![
862 Some(ColumnValue::String("patient-1".to_owned())),
863 Some(ColumnValue::String("practice-key-v1".to_owned())),
864 Some(ColumnValue::String("Identity".to_owned())),
865 ];
866 assert_eq!(
867 config.key_id_for(&table, &row).expect("selects key"),
868 "practice-key-v1"
869 );
870 }
871
872 #[test]
873 fn debug_render_redacts_key_material() {
874 let mut config = EncryptionConfig::default();
875 config
876 .keys
877 .insert("practice-key-v1".to_owned(), vec![0xAB; 32]);
878 config
879 .key_id_columns
880 .insert("patients".to_owned(), "encryption_key_id".to_owned());
881 let rendered = format!("{config:?}");
882 assert!(rendered.contains("practice-key-v1"), "{rendered}");
883 assert!(rendered.contains("<32 bytes>"), "{rendered}");
884 assert!(rendered.contains("encryption_key_id"), "{rendered}");
885 assert!(!rendered.contains("171"), "{rendered}");
887 assert!(!rendered.contains("0xAB"), "{rendered}");
888 }
889
890 fn table(names: &[&str]) -> TableSchema {
891 let columns: Vec<Column> = names
892 .iter()
893 .map(|n| Column {
894 name: (*n).to_owned(),
895 ty: ColumnType::String,
896 nullable: true,
897 })
898 .collect();
899 TableSchema {
900 name: "t".to_owned(),
901 columns: columns.clone(),
902 wire_columns: columns,
903 primary_key: "id".to_owned(),
904 pk_index: 0,
905 scope_variables: Vec::new(),
906 indexes: Vec::new(),
907 fts_indexes: Vec::new(),
908 encrypted_columns: Vec::new(),
909 }
910 }
911
912 fn map(entries: &[(&str, &str)]) -> Map<String, Value> {
913 entries
914 .iter()
915 .map(|(k, v)| ((*k).to_owned(), json!(v)))
916 .collect()
917 }
918
919 #[test]
920 fn camel_keys_normalize_to_sql_names() {
921 let t = table(&["id", "list_id", "updated_at_ms"]);
922 let out = normalize_values_casing(
923 &t,
924 map(&[("id", "x"), ("listId", "l"), ("updatedAtMs", "9")]),
925 )
926 .expect("normalizes");
927 assert_eq!(out.get("list_id"), Some(&json!("l")));
928 assert_eq!(out.get("updated_at_ms"), Some(&json!("9")));
929 assert!(!out.contains_key("listId"));
930 }
931
932 #[test]
933 fn snake_keys_pass_through() {
934 let t = table(&["id", "list_id"]);
935 let out = normalize_values_casing(&t, map(&[("id", "x"), ("list_id", "l")])).expect("ok");
936 assert_eq!(out.get("list_id"), Some(&json!("l")));
937 }
938
939 #[test]
940 fn both_casings_for_one_column_is_an_error() {
941 let t = table(&["id", "list_id"]);
942 let err = normalize_values_casing(&t, map(&[("list_id", "a"), ("listId", "b")]))
943 .expect_err("rejects");
944 assert!(err.contains("both snake_case and camelCase"), "{err}");
945 }
946
947 #[test]
948 fn an_alias_colliding_with_a_real_column_never_steals_it() {
949 let t = table(&["id", "col_2", "col2"]);
951 let out = normalize_values_casing(&t, map(&[("id", "x"), ("col2", "v")])).expect("ok");
952 assert_eq!(out.get("col2"), Some(&json!("v")));
953 assert!(!out.contains_key("col_2"));
954 }
955
956 #[test]
957 fn unknown_and_internal_columns_fail_loud() {
958 let t = table(&["id", "title"]);
959 let unknown = normalize_values_casing(&t, map(&[("id", "x"), ("typo", "v")]))
960 .expect_err("unknown field");
961 assert!(unknown.contains("unknown column"), "{unknown}");
962 let internal = normalize_values_casing(&t, map(&[("id", "x"), ("_sync_version", "1")]))
963 .expect_err("internal field");
964 assert!(internal.contains("internal sync column"), "{internal}");
965 }
966}
967
968#[cfg(test)]
969mod sparse_key_tests {
970 use ssp2::segment::{Column, ColumnType, ColumnValue, Row};
971
972 use super::EncryptionConfig;
973 use crate::schema::{EncryptedColumn, TableSchema};
974
975 #[cfg(feature = "e2ee")]
976 use super::encode_sparse_row_json;
977 #[cfg(feature = "e2ee")]
978 use serde_json::{json, Map, Value};
979
980 fn keyed_table() -> TableSchema {
981 let columns = vec![
982 Column {
983 name: "id".to_owned(),
984 ty: ColumnType::String,
985 nullable: false,
986 },
987 Column {
988 name: "encryption_key_id".to_owned(),
989 ty: ColumnType::String,
990 nullable: true,
991 },
992 Column {
993 name: "note".to_owned(),
994 ty: ColumnType::String,
995 nullable: true,
996 },
997 ];
998 let wire_columns = vec![
999 Column {
1000 name: "id".to_owned(),
1001 ty: ColumnType::String,
1002 nullable: false,
1003 },
1004 Column {
1005 name: "encryption_key_id".to_owned(),
1006 ty: ColumnType::String,
1007 nullable: true,
1008 },
1009 Column {
1010 name: "note".to_owned(),
1011 ty: ColumnType::Bytes,
1012 nullable: true,
1013 },
1014 ];
1015 TableSchema {
1016 name: "t".to_owned(),
1017 columns,
1018 wire_columns,
1019 primary_key: "id".to_owned(),
1020 pk_index: 0,
1021 scope_variables: Vec::new(),
1022 indexes: Vec::new(),
1023 fts_indexes: Vec::new(),
1024 encrypted_columns: vec![EncryptedColumn {
1025 index: 2,
1026 declared_type: "string".to_owned(),
1027 }],
1028 }
1029 }
1030
1031 fn keyed_config() -> EncryptionConfig {
1032 let mut config = EncryptionConfig::default();
1033 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
1034 config
1035 .key_id_columns
1036 .insert("t".to_owned(), "encryption_key_id".to_owned());
1037 config
1038 }
1039
1040 #[cfg(feature = "e2ee")]
1041 fn values(entries: &[(&str, Value)]) -> Map<String, Value> {
1042 entries
1043 .iter()
1044 .map(|(key, value)| ((*key).to_owned(), value.clone()))
1045 .collect()
1046 }
1047
1048 #[test]
1049 fn key_selection_failures_carry_the_encode_seam_code() {
1050 let table = keyed_table();
1051 let config = keyed_config();
1052 let row: Row = vec![
1054 Some(ColumnValue::String("r1".to_owned())),
1055 None,
1056 Some(ColumnValue::String("hi".to_owned())),
1057 ];
1058 let error = config.key_id_for(&table, &row).expect_err("rejects");
1059 assert!(error.starts_with("client.encrypt_failed"), "{error}");
1060 assert!(!error.starts_with("client.decrypt_failed"), "{error}");
1061 }
1062
1063 #[cfg(feature = "e2ee")]
1064 #[test]
1065 fn sparse_patch_without_an_encrypted_slot_needs_no_key() {
1066 let table = keyed_table();
1067 let config = EncryptionConfig::default();
1069 let payload =
1070 encode_sparse_row_json(&table, "r1", &values(&[("id", json!("r1"))]), &config, None)
1071 .expect("encodes without key resolution");
1072 assert!(!payload.is_empty());
1073 }
1074
1075 #[cfg(feature = "e2ee")]
1076 #[test]
1077 fn sparse_patch_falls_back_to_the_stored_key_id() {
1078 let table = keyed_table();
1079 let config = keyed_config();
1080 let sparse = values(&[("id", json!("r1")), ("note", json!("hi"))]);
1082 let error = encode_sparse_row_json(&table, "r1", &sparse, &config, None)
1083 .expect_err("absent selector with no fallback fails");
1084 assert!(error.contains("client.encrypt_failed"), "{error}");
1085 let fallback: Row = vec![
1086 Some(ColumnValue::String("r1".to_owned())),
1087 Some(ColumnValue::String("k1".to_owned())),
1088 None,
1089 ];
1090 encode_sparse_row_json(&table, "r1", &sparse, &config, Some(&fallback))
1091 .expect("stored key id encrypts the patch");
1092 }
1093
1094 #[cfg(feature = "e2ee")]
1095 #[test]
1096 fn sparse_patch_keeps_a_present_null_selector() {
1097 let table = keyed_table();
1098 let config = keyed_config();
1099 let sparse = values(&[
1103 ("id", json!("r1")),
1104 ("encryption_key_id", json!(null)),
1105 ("note", json!("hi")),
1106 ]);
1107 let fallback: Row = vec![
1108 Some(ColumnValue::String("r1".to_owned())),
1109 Some(ColumnValue::String("k1".to_owned())),
1110 None,
1111 ];
1112 let error = encode_sparse_row_json(&table, "r1", &sparse, &config, Some(&fallback))
1113 .expect_err("a present NULL selector must not read the stored fallback");
1114 assert!(error.contains("client.encrypt_failed"), "{error}");
1115 }
1116}