1use std::collections::HashMap;
6
7use serde_json::{Map, Value};
8use ssp2::primitives::{RawJson, Reader, Writer};
9use ssp2::segment::{decode_row, encode_row, Column, ColumnType, ColumnValue, Row};
10use ssp2::util::utf16_lt;
11
12use crate::schema::TableSchema;
13
14#[derive(Debug, Clone, Default)]
19pub struct EncryptionConfig {
20 pub keys: HashMap<String, Vec<u8>>,
21}
22
23impl EncryptionConfig {
24 pub fn is_empty(&self) -> bool {
25 self.keys.is_empty()
26 }
27
28 pub fn key_id_for(&self, table: &str, _row_id: &str) -> String {
30 table.to_owned()
31 }
32}
33
34pub fn snake_to_camel(name: &str) -> String {
39 let is_mappable = {
40 let bare = name.trim_start_matches('_');
41 !bare.is_empty()
42 && bare.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
43 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
44 };
45 if !is_mappable {
46 return name.to_owned();
47 }
48 let lead_len = name.len() - name.trim_start_matches('_').len();
49 let (lead, bare) = name.split_at(lead_len);
50 let trail_len = bare.len() - bare.trim_end_matches('_').len();
51 let (middle, trail) = bare.split_at(bare.len() - trail_len);
52 let mut segments = middle.split('_').filter(|s| !s.is_empty());
53 let Some(first) = segments.next() else {
54 return name.to_owned();
55 };
56 let mut out = String::with_capacity(name.len());
57 out.push_str(lead);
58 out.push_str(first);
59 for segment in segments {
60 let mut chars = segment.chars();
61 if let Some(head) = chars.next() {
62 out.push(head.to_ascii_uppercase());
63 out.push_str(chars.as_str());
64 }
65 }
66 out.push_str(trail);
67 out
68}
69
70pub fn normalize_values_casing(
78 table: &TableSchema,
79 mut values: Map<String, Value>,
80) -> Result<Map<String, Value>, String> {
81 for column in &table.columns {
82 let camel = snake_to_camel(&column.name);
83 if camel == column.name {
84 continue;
85 }
86 if table.columns.iter().any(|c| c.name == camel) {
89 continue;
90 }
91 if table
92 .columns
93 .iter()
94 .filter(|c| snake_to_camel(&c.name) == camel)
95 .count()
96 > 1
97 {
98 continue;
99 }
100 if let Some(value) = values.remove(&camel) {
101 if values.contains_key(&column.name) {
102 return Err(format!(
103 "table {:?}: column {:?} appears twice in mutation values (as both snake_case and camelCase) — pass it once",
104 table.name, column.name
105 ));
106 }
107 values.insert(column.name.clone(), value);
108 }
109 }
110 for key in values.keys() {
111 if !table.columns.iter().any(|column| column.name == *key) {
112 if key.starts_with("_sync_") {
113 return Err(format!(
114 "table {:?}: {:?} is an internal sync column and cannot appear in mutation values",
115 table.name, key
116 ));
117 }
118 return Err(format!(
119 "table {:?}: unknown column {:?} in mutation values (snake_case and camelCase keys are accepted)",
120 table.name, key
121 ));
122 }
123 }
124 Ok(values)
125}
126
127pub fn bytes_to_hex(bytes: &[u8]) -> String {
128 let mut out = String::with_capacity(bytes.len() * 2);
129 for b in bytes {
130 out.push_str(&format!("{b:02x}"));
131 }
132 out
133}
134
135pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
136 if !hex.len().is_multiple_of(2) {
137 return Err("odd-length hex string".to_owned());
138 }
139 let mut out = Vec::with_capacity(hex.len() / 2);
140 let bytes = hex.as_bytes();
141 for pair in bytes.chunks(2) {
142 let s = std::str::from_utf8(pair).map_err(|_| "non-ASCII hex".to_owned())?;
143 out.push(u8::from_str_radix(s, 16).map_err(|e| format!("bad hex: {e}"))?);
144 }
145 Ok(out)
146}
147
148pub fn json_to_column_value(
151 column: &Column,
152 value: Option<&Value>,
153) -> Result<Option<ColumnValue>, String> {
154 let value = match value {
155 None | Some(Value::Null) => return Ok(None),
156 Some(v) => v,
157 };
158 let fail = |expected: &str| {
159 Err(format!(
160 "column {:?}: expected {expected}, got {value}",
161 column.name
162 ))
163 };
164 match column.ty {
165 ColumnType::String => match value.as_str() {
166 Some(s) => Ok(Some(ColumnValue::String(s.to_owned()))),
167 None => fail("a string"),
168 },
169 ColumnType::Integer => match value.as_i64() {
170 Some(i) => Ok(Some(ColumnValue::Integer(i))),
171 None => fail("an integer"),
172 },
173 ColumnType::Float => match value.as_f64() {
174 Some(f) => Ok(Some(ColumnValue::Float(f))),
175 None => fail("a number"),
176 },
177 ColumnType::Boolean => match value.as_bool() {
178 Some(b) => Ok(Some(ColumnValue::Boolean(b))),
179 None => fail("a boolean"),
180 },
181 ColumnType::Json => match value.as_str() {
183 Some(s) => Ok(Some(ColumnValue::Json(RawJson(s.to_owned())))),
184 None => fail("a raw JSON string"),
185 },
186 ColumnType::BlobRef => match value.as_str() {
188 Some(s) => Ok(Some(ColumnValue::BlobRef(RawJson(s.to_owned())))),
189 None => fail("a raw BlobRef JSON string"),
190 },
191 ColumnType::Bytes => match value.get("$bytes").and_then(Value::as_str) {
192 Some(hex) => Ok(Some(ColumnValue::Bytes(hex_to_bytes(hex)?))),
193 None => fail("a {\"$bytes\": hex} object"),
194 },
195 ColumnType::Crdt => match value.get("$bytes").and_then(Value::as_str) {
197 Some(hex) => Ok(Some(ColumnValue::Crdt(hex_to_bytes(hex)?))),
198 None => fail("a {\"$bytes\": hex} object"),
199 },
200 }
201}
202
203pub fn column_value_to_json(value: &Option<ColumnValue>) -> Value {
205 match value {
206 None => Value::Null,
207 Some(ColumnValue::String(s)) => Value::from(s.clone()),
208 Some(ColumnValue::Integer(i)) => Value::from(*i),
209 Some(ColumnValue::Float(f)) => {
210 serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number)
211 }
212 Some(ColumnValue::Boolean(b)) => Value::from(*b),
213 Some(ColumnValue::Json(raw)) => Value::from(raw.0.clone()),
214 Some(ColumnValue::BlobRef(raw)) => Value::from(raw.0.clone()),
215 Some(ColumnValue::Bytes(bytes)) => {
216 let mut map = Map::new();
217 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
218 Value::Object(map)
219 }
220 Some(ColumnValue::Crdt(bytes)) => {
221 let mut map = Map::new();
222 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
223 Value::Object(map)
224 }
225 }
226}
227
228pub fn encode_row_json(
233 table: &TableSchema,
234 row_id: &str,
235 values: &Map<String, Value>,
236 encryption: &EncryptionConfig,
237) -> Result<Vec<u8>, String> {
238 let mut row: Row = Vec::with_capacity(table.columns.len());
240 for column in &table.columns {
241 let value = json_to_column_value(column, values.get(&column.name))?;
242 if value.is_none() && !column.nullable {
243 return Err(format!(
244 "table {:?}: column {:?} is not nullable (§6.1 full-row payloads)",
245 table.name, column.name
246 ));
247 }
248 row.push(value);
249 }
250 if table.has_encrypted_columns() {
251 encrypt_row(table, row_id, &mut row, encryption)?;
252 }
253 let mut w = Writer::new();
255 encode_row(&mut w, &table.wire_columns, &row);
256 Ok(w.into_bytes())
257}
258
259pub fn decode_row_bytes(
263 table: &TableSchema,
264 payload: &[u8],
265 encryption: &EncryptionConfig,
266) -> Result<Row, String> {
267 let mut r = Reader::new(payload);
268 let mut row = decode_row(&mut r, &table.wire_columns).map_err(|e| e.to_string())?;
270 if !r.is_empty() {
271 return Err("row payload has trailing bytes".to_owned());
272 }
273 if table.has_encrypted_columns() {
274 decrypt_row(table, &mut row, encryption)?;
275 }
276 Ok(row)
277}
278
279pub fn decrypt_segment_row(
283 table: &TableSchema,
284 row: &mut Row,
285 encryption: &EncryptionConfig,
286) -> Result<(), String> {
287 decrypt_row(table, row, encryption)
288}
289
290#[cfg(feature = "e2ee")]
293fn encrypt_row(
294 table: &TableSchema,
295 row_id: &str,
296 row: &mut Row,
297 encryption: &EncryptionConfig,
298) -> Result<(), String> {
299 use rand_core::RngCore;
300 use ssp2::crypto::{encrypt_value, NONCE_LENGTH};
301 for enc in &table.encrypted_columns {
302 let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
303 continue; };
305 let plain = column_value_to_plain(value)?;
306 let key_id = encryption.key_id_for(&table.name, row_id);
307 let key = encryption
308 .keys
309 .get(&key_id)
310 .ok_or_else(|| format!("client.decrypt_failed: no key for keyId {key_id:?}"))?;
311 let mut nonce = [0u8; NONCE_LENGTH];
312 rand_core::OsRng.fill_bytes(&mut nonce);
313 let envelope = encrypt_value(&plain, &key_id, key, nonce)?;
314 row[enc.index] = Some(ColumnValue::Bytes(envelope));
315 }
316 Ok(())
317}
318
319#[cfg(feature = "e2ee")]
320fn decrypt_row(
321 table: &TableSchema,
322 row: &mut Row,
323 encryption: &EncryptionConfig,
324) -> Result<(), String> {
325 use ssp2::crypto::{decrypt_value, DeclaredType};
326 for enc in &table.encrypted_columns {
327 let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
328 continue;
329 };
330 let ColumnValue::Bytes(envelope) = value else {
331 return Err(format!(
332 "client.decrypt_failed: encrypted column at index {} is not bytes",
333 enc.index
334 ));
335 };
336 let declared = DeclaredType::from_name(&enc.declared_type)
337 .ok_or_else(|| format!("unknown declaredType {:?}", enc.declared_type))?;
338 let keys = &encryption.keys;
339 let plain = decrypt_value(declared, envelope, |id| keys.get(id).cloned())
340 .map_err(|e| e.to_string())?;
341 row[enc.index] = Some(plain_to_column_value(plain));
342 }
343 Ok(())
344}
345
346#[cfg(not(feature = "e2ee"))]
349fn encrypt_row(
350 table: &TableSchema,
351 _row_id: &str,
352 _row: &mut Row,
353 _encryption: &EncryptionConfig,
354) -> Result<(), String> {
355 Err(format!(
356 "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
357 table.name
358 ))
359}
360
361#[cfg(not(feature = "e2ee"))]
362fn decrypt_row(
363 table: &TableSchema,
364 _row: &mut Row,
365 _encryption: &EncryptionConfig,
366) -> Result<(), String> {
367 Err(format!(
368 "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
369 table.name
370 ))
371}
372
373#[cfg(feature = "e2ee")]
374fn column_value_to_plain(value: &ColumnValue) -> Result<ssp2::crypto::PlainValue, String> {
375 use ssp2::crypto::PlainValue;
376 Ok(match value {
377 ColumnValue::String(s) => PlainValue::String(s.clone()),
378 ColumnValue::Integer(i) => PlainValue::Integer(*i),
379 ColumnValue::Float(f) => PlainValue::Float(*f),
380 ColumnValue::Boolean(b) => PlainValue::Boolean(*b),
381 ColumnValue::Json(j) => PlainValue::Json(j.0.clone()),
382 ColumnValue::BlobRef(j) => PlainValue::BlobRef(j.0.clone()),
383 ColumnValue::Bytes(b) => PlainValue::Bytes(b.clone()),
384 ColumnValue::Crdt(_) => return Err("crdt columns cannot be encrypted (§5.11)".to_owned()),
385 })
386}
387
388#[cfg(feature = "e2ee")]
389fn plain_to_column_value(value: ssp2::crypto::PlainValue) -> ColumnValue {
390 use ssp2::crypto::PlainValue;
391 match value {
392 PlainValue::String(s) => ColumnValue::String(s),
393 PlainValue::Integer(i) => ColumnValue::Integer(i),
394 PlainValue::Float(f) => ColumnValue::Float(f),
395 PlainValue::Boolean(b) => ColumnValue::Boolean(b),
396 PlainValue::Json(s) => ColumnValue::Json(RawJson(s)),
397 PlainValue::BlobRef(s) => ColumnValue::BlobRef(RawJson(s)),
398 PlainValue::Bytes(b) => ColumnValue::Bytes(b),
399 }
400}
401
402pub fn render_row_id(value: &Option<ColumnValue>) -> Result<String, String> {
404 match value {
405 Some(ColumnValue::String(s)) => Ok(s.clone()),
406 Some(ColumnValue::Integer(i)) => Ok(i.to_string()),
407 Some(ColumnValue::Float(f)) => Ok(f.to_string()),
408 Some(ColumnValue::Boolean(b)) => Ok(b.to_string()),
409 Some(ColumnValue::Json(raw)) => Ok(raw.0.clone()),
410 Some(ColumnValue::BlobRef(_)) => Err("blob_ref column cannot be a rowId".to_owned()),
411 Some(ColumnValue::Bytes(_)) => Err("bytes column cannot be a rowId".to_owned()),
412 Some(ColumnValue::Crdt(_)) => Err("crdt column cannot be a rowId".to_owned()),
413 None => Err("primary key value is missing".to_owned()),
414 }
415}
416
417pub fn render_row_id_json(value: Option<&Value>) -> Result<String, String> {
419 match value {
420 Some(Value::String(s)) => Ok(s.clone()),
421 Some(Value::Number(n)) => Ok(n.to_string()),
422 Some(Value::Bool(b)) => Ok(b.to_string()),
423 _ => Err("primary key value is missing or not renderable".to_owned()),
424 }
425}
426
427fn sort_utf16(values: &mut [String]) {
428 values.sort_by(|a, b| {
429 if utf16_lt(a, b) {
430 std::cmp::Ordering::Less
431 } else if utf16_lt(b, a) {
432 std::cmp::Ordering::Greater
433 } else {
434 std::cmp::Ordering::Equal
435 }
436 });
437}
438
439pub fn sort_scope_map(map: &mut [(String, Vec<String>)]) {
442 map.sort_by(|a, b| {
443 if utf16_lt(&a.0, &b.0) {
444 std::cmp::Ordering::Less
445 } else if utf16_lt(&b.0, &a.0) {
446 std::cmp::Ordering::Greater
447 } else {
448 std::cmp::Ordering::Equal
449 }
450 });
451}
452
453pub fn canonical_scope_json(scopes: &[(String, Vec<String>)]) -> String {
456 let mut entries: Vec<(String, Vec<String>)> = scopes.to_vec();
457 sort_scope_map(&mut entries);
458 let mut out = String::from("{");
459 for (i, (key, values)) in entries.iter().enumerate() {
460 if i > 0 {
461 out.push(',');
462 }
463 let mut sorted = values.clone();
464 sort_utf16(&mut sorted);
465 sorted.dedup();
466 out.push_str(&serde_json::to_string(key).expect("string serializes"));
467 out.push_str(":[");
468 for (j, value) in sorted.iter().enumerate() {
469 if j > 0 {
470 out.push(',');
471 }
472 out.push_str(&serde_json::to_string(value).expect("string serializes"));
473 }
474 out.push(']');
475 }
476 out.push('}');
477 out
478}
479
480pub fn scope_map_to_json(scopes: &[(String, Vec<String>)]) -> Value {
482 let mut map = Map::new();
483 for (key, values) in scopes {
484 map.insert(
485 key.clone(),
486 Value::Array(values.iter().map(|v| Value::from(v.clone())).collect()),
487 );
488 }
489 Value::Object(map)
490}
491
492pub fn json_to_scope_map(value: &Value) -> Result<Vec<(String, Vec<String>)>, String> {
494 let object = value
495 .as_object()
496 .ok_or_else(|| "scope map must be an object".to_owned())?;
497 let mut out = Vec::with_capacity(object.len());
498 for (key, values) in object {
499 let list = values
500 .as_array()
501 .ok_or_else(|| format!("scope values for {key:?} must be a list (§0)"))?;
502 let mut strings = Vec::with_capacity(list.len());
503 for v in list {
504 strings.push(
505 v.as_str()
506 .ok_or_else(|| format!("scope value for {key:?} is not a string"))?
507 .to_owned(),
508 );
509 }
510 out.push((key.clone(), strings));
511 }
512 Ok(out)
513}
514
515#[cfg(test)]
516mod naming_tests {
517 use serde_json::{json, Map, Value};
518 use ssp2::segment::{Column, ColumnType};
519
520 use super::{normalize_values_casing, snake_to_camel};
521 use crate::schema::TableSchema;
522
523 #[test]
524 fn snake_to_camel_pinned_vectors() {
525 for (input, expected) in [
526 ("created_at", "createdAt"),
527 ("col_2", "col2"),
528 ("user_id", "userId"),
529 ("_internal", "_internal"),
530 ("__foo_bar", "__fooBar"),
531 ("row_", "row_"),
532 ("id_url", "idUrl"),
533 ("api_key", "apiKey"),
534 ("title", "title"),
535 ("alreadyCamel", "alreadyCamel"),
536 ("a__b", "aB"),
537 ("_lead_and_trail_", "_leadAndTrail_"),
538 ("count(*)", "count(*)"),
539 ] {
540 assert_eq!(snake_to_camel(input), expected, "input {input:?}");
541 }
542 }
543
544 fn table(names: &[&str]) -> TableSchema {
545 let columns: Vec<Column> = names
546 .iter()
547 .map(|n| Column {
548 name: (*n).to_owned(),
549 ty: ColumnType::String,
550 nullable: true,
551 })
552 .collect();
553 TableSchema {
554 name: "t".to_owned(),
555 columns: columns.clone(),
556 wire_columns: columns,
557 primary_key: "id".to_owned(),
558 pk_index: 0,
559 scope_variables: Vec::new(),
560 indexes: Vec::new(),
561 encrypted_columns: Vec::new(),
562 }
563 }
564
565 fn map(entries: &[(&str, &str)]) -> Map<String, Value> {
566 entries
567 .iter()
568 .map(|(k, v)| ((*k).to_owned(), json!(v)))
569 .collect()
570 }
571
572 #[test]
573 fn camel_keys_normalize_to_sql_names() {
574 let t = table(&["id", "list_id", "updated_at_ms"]);
575 let out = normalize_values_casing(
576 &t,
577 map(&[("id", "x"), ("listId", "l"), ("updatedAtMs", "9")]),
578 )
579 .expect("normalizes");
580 assert_eq!(out.get("list_id"), Some(&json!("l")));
581 assert_eq!(out.get("updated_at_ms"), Some(&json!("9")));
582 assert!(!out.contains_key("listId"));
583 }
584
585 #[test]
586 fn snake_keys_pass_through() {
587 let t = table(&["id", "list_id"]);
588 let out = normalize_values_casing(&t, map(&[("id", "x"), ("list_id", "l")])).expect("ok");
589 assert_eq!(out.get("list_id"), Some(&json!("l")));
590 }
591
592 #[test]
593 fn both_casings_for_one_column_is_an_error() {
594 let t = table(&["id", "list_id"]);
595 let err = normalize_values_casing(&t, map(&[("list_id", "a"), ("listId", "b")]))
596 .expect_err("rejects");
597 assert!(err.contains("both snake_case and camelCase"), "{err}");
598 }
599
600 #[test]
601 fn an_alias_colliding_with_a_real_column_never_steals_it() {
602 let t = table(&["id", "col_2", "col2"]);
604 let out = normalize_values_casing(&t, map(&[("id", "x"), ("col2", "v")])).expect("ok");
605 assert_eq!(out.get("col2"), Some(&json!("v")));
606 assert!(!out.contains_key("col_2"));
607 }
608
609 #[test]
610 fn unknown_and_internal_columns_fail_loud() {
611 let t = table(&["id", "title"]);
612 let unknown = normalize_values_casing(&t, map(&[("id", "x"), ("typo", "v")]))
613 .expect_err("unknown field");
614 assert!(unknown.contains("unknown column"), "{unknown}");
615 let internal = normalize_values_casing(&t, map(&[("id", "x"), ("_sync_version", "1")]))
616 .expect_err("internal field");
617 assert!(internal.contains("internal sync column"), "{internal}");
618 }
619}