Skip to main content

syncular_client/
values.rs

1//! Value conversions at the client's edges: driver JSON (`{"$bytes": hex}`
2//! convention) ↔ the §2.4 row-codec values ↔ SQLite storage, plus the §11.2
3//! canonical scope JSON (contractual across implementations).
4
5use 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/// §5.11 client-side encryption keys (`keyId → 32-byte key`) plus optional
15/// key selection. Empty ⇒ E2EE off. `key_id_for` defaults to per-table
16/// (`keyId = table`). Always present on the client; the crypto is compiled
17/// only under the `e2ee` feature.
18#[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    /// §5.11 default key selection: per-table (`keyId = table`).
29    pub fn key_id_for(&self, table: &str, _row_id: &str) -> String {
30        table.to_owned()
31    }
32}
33
34/// The pinned §12 snake→camel conversion (DESIGN-queries.md §5) — the Rust
35/// copy of the typegen/TS-client algorithm, kept in lockstep by shared test
36/// vectors. Leading/trailing `_` runs are preserved; middle segments split
37/// on `_` (doubled underscores drop); no acronym awareness.
38pub 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
70/// §5 mutate key normalization: accept BOTH casings for upsert value keys —
71/// the SQL-truth snake_case and the generated row types' camelCase. A camel
72/// key renames to its column's SQL name when that is unambiguous (the alias
73/// equals no other column's exact name, and no two columns share it). A
74/// column given in both casings is an error. Unknown keys pass through
75/// unchanged (they are ignored downstream, as before).
76pub 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        // Exact names always win; an alias colliding with another column's
86        // real name (or with another column's alias) is not an alias.
87        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
133/// Driver JSON value → row-codec value for one column. `null`/absent maps to
134/// NULL; bytes travel as `{"$bytes": "<hex>"}`.
135pub 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        // `json` columns stay raw strings at the driver boundary (§2.4).
167        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        // `blob_ref` (tag 7) also stays a raw string at the boundary (§5.9.1).
172        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        // §5.10: crdt bytes cross the boundary as {"$bytes": hex}, like bytes.
181        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
188/// Row-codec value → driver JSON value.
189pub 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
213/// Encode one full row (driver JSON values keyed by column name) with the
214/// generated row codec (§2.4, §6.1). §5.11: encrypted columns are encrypted
215/// here — the encode-at-send seam — before the codec serializes them as
216/// ciphertext-envelope `bytes` using `wire_columns`.
217pub 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    // Build the row from the LOCAL (declared-type) columns.
224    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    // Serialize with the WIRE columns (encrypted columns are `bytes`).
232    let mut w = Writer::new();
233    encode_row(&mut w, &table.wire_columns, &row);
234    Ok(w.into_bytes())
235}
236
237/// Decode one row-codec payload; trailing bytes are a decode error. §5.11:
238/// encrypted columns are decrypted here — the apply seam — back to their
239/// declared-type plaintext value for the local mirror.
240pub 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    // Decode with the WIRE columns (encrypted columns arrive as `bytes`).
247    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
257/// §5.11: decrypt the encrypted columns of an already-decoded segment row
258/// (rows segments decode via their own column table, so decryption is a
259/// post-decode pass over the positional values).
260pub 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// -- §5.11 encrypt/decrypt seam (feature-gated) ------------------------------
269
270#[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; // NULL stays NULL (§5.11)
282        };
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/// Without the `e2ee` feature, a schema with encrypted columns is a
325/// misconfiguration — fail loud rather than ship plaintext.
326#[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
380/// Render a primary-key value as the wire `rowId` string.
381pub 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
395/// Driver JSON value → wire `rowId` string (for optimistic upserts).
396pub 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
417/// Sort scope-map keys into ascending code-unit order (the canonical `map`
418/// encoding of the Conventions section).
419pub 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
431/// §11.2 canonical JSON of a scope map: keys sorted by code-unit, value
432/// lists sorted and deduplicated, no insignificant whitespace.
433pub 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
458/// Scope map as a driver JSON object (`variable → list of values`, §3.2).
459pub 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
470/// Driver JSON object → scope map, preserving key order.
471pub 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        // `col_2` camel-maps to `col2`, which IS a column: exact wins, no rename.
581        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}