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