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::{
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/// §5.11 client-side encryption keys (`keyId → 32-byte key`) plus optional
18/// key selection. Empty ⇒ E2EE off. `key_id_for` defaults to per-table
19/// (`keyId = table`). Always present on the client; the crypto is compiled
20/// only under the `e2ee` feature.
21#[derive(Clone, Default)]
22pub struct EncryptionConfig {
23    pub keys: HashMap<String, Vec<u8>>,
24    /// Portable per-table selector: the named non-encrypted string column in
25    /// each plaintext row contains the key id used for new envelopes.
26    pub key_id_columns: HashMap<String, String>,
27}
28
29/// Manual Debug so a stray `{config:?}` log line stays free of key material:
30/// key entries render as `id → <N bytes>`.
31impl 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        // Best-effort native key hygiene: replacement, preflight entry, and
54        // client shutdown overwrite every owned key buffer before release.
55        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    /// §5.11 portable key selection: configured row column, then table name.
67    pub fn key_id_for(&self, table: &TableSchema, row: &Row) -> Result<String, String> {
68        let Some(column_name) = self.key_id_columns.get(&table.name) else {
69            return Ok(table.name.clone());
70        };
71        let Some(index) = table
72            .columns
73            .iter()
74            .position(|column| &column.name == column_name)
75        else {
76            return Err(format!(
77                "client.decrypt_failed: encryption key-id column {column_name:?} is not present on table {:?}",
78                table.name
79            ));
80        };
81        if table
82            .encrypted_columns
83            .iter()
84            .any(|column| column.index == index)
85        {
86            return Err(format!(
87                "client.decrypt_failed: encryption key-id column {column_name:?} on table {:?} must not be encrypted",
88                table.name
89            ));
90        }
91        match row.get(index).and_then(|value| value.as_ref()) {
92            Some(ColumnValue::String(key_id)) if !key_id.is_empty() => Ok(key_id.clone()),
93            _ => Err(format!(
94                "client.decrypt_failed: encryption key-id column {column_name:?} on table {:?} must contain a non-empty string",
95                table.name
96            )),
97        }
98    }
99}
100
101/// The pinned snake→camel conversion. The Rust
102/// copy of the typegen/TS-client algorithm, kept in lockstep by shared test
103/// vectors. Leading/trailing `_` runs are preserved; middle segments split
104/// on `_` (doubled underscores drop); no acronym awareness.
105pub fn snake_to_camel(name: &str) -> String {
106    let is_mappable = {
107        let bare = name.trim_start_matches('_');
108        !bare.is_empty()
109            && bare.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
110            && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
111    };
112    if !is_mappable {
113        return name.to_owned();
114    }
115    let lead_len = name.len() - name.trim_start_matches('_').len();
116    let (lead, bare) = name.split_at(lead_len);
117    let trail_len = bare.len() - bare.trim_end_matches('_').len();
118    let (middle, trail) = bare.split_at(bare.len() - trail_len);
119    let mut segments = middle.split('_').filter(|s| !s.is_empty());
120    let Some(first) = segments.next() else {
121        return name.to_owned();
122    };
123    let mut out = String::with_capacity(name.len());
124    out.push_str(lead);
125    out.push_str(first);
126    for segment in segments {
127        let mut chars = segment.chars();
128        if let Some(head) = chars.next() {
129            out.push(head.to_ascii_uppercase());
130            out.push_str(chars.as_str());
131        }
132    }
133    out.push_str(trail);
134    out
135}
136
137/// §5 mutate key normalization: accept BOTH casings for upsert value keys —
138/// the SQL-truth snake_case and the generated row types' camelCase. A camel
139/// key renames to its column's SQL name when that is unambiguous (the alias
140/// equals no other column's exact name, and no two columns share it). A
141/// column given in both casings is an error. Unknown keys fail loud, matching
142/// the TypeScript core; silently dropping an app field would corrupt a
143/// full-row mutation while appearing successful.
144pub fn normalize_values_casing(
145    table: &TableSchema,
146    mut values: Map<String, Value>,
147) -> Result<Map<String, Value>, String> {
148    for column in &table.columns {
149        let camel = snake_to_camel(&column.name);
150        if camel == column.name {
151            continue;
152        }
153        // Exact names always win; an alias colliding with another column's
154        // real name (or with another column's alias) is not an alias.
155        if table.columns.iter().any(|c| c.name == camel) {
156            continue;
157        }
158        if table
159            .columns
160            .iter()
161            .filter(|c| snake_to_camel(&c.name) == camel)
162            .count()
163            > 1
164        {
165            continue;
166        }
167        if let Some(value) = values.remove(&camel) {
168            if values.contains_key(&column.name) {
169                return Err(format!(
170                    "table {:?}: column {:?} appears twice in mutation values (as both snake_case and camelCase) — pass it once",
171                    table.name, column.name
172                ));
173            }
174            values.insert(column.name.clone(), value);
175        }
176    }
177    for key in values.keys() {
178        if !table.columns.iter().any(|column| column.name == *key) {
179            if key.starts_with("_sync_") {
180                return Err(format!(
181                    "table {:?}: {:?} is an internal sync column and cannot appear in mutation values",
182                    table.name, key
183                ));
184            }
185            return Err(format!(
186                "table {:?}: unknown column {:?} in mutation values (snake_case and camelCase keys are accepted)",
187                table.name, key
188            ));
189        }
190    }
191    Ok(values)
192}
193
194pub fn bytes_to_hex(bytes: &[u8]) -> String {
195    let mut out = String::with_capacity(bytes.len() * 2);
196    for b in bytes {
197        out.push(char::from(b"0123456789abcdef"[usize::from(b >> 4)]));
198        out.push(char::from(b"0123456789abcdef"[usize::from(b & 15)]));
199    }
200    out
201}
202
203pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
204    if !hex.len().is_multiple_of(2) {
205        return Err("odd-length hex string".to_owned());
206    }
207    let mut out = Vec::with_capacity(hex.len() / 2);
208    let nibble = |byte| match byte {
209        b'0'..=b'9' => byte - b'0',
210        b'a'..=b'f' => byte - b'a' + 10,
211        b'A'..=b'F' => byte - b'A' + 10,
212        _ => u8::MAX,
213    };
214    for pair in hex.as_bytes().as_chunks::<2>().0 {
215        // Preserve the prior radix parser's accepted +digit pair as well as
216        // uppercase input. Canonical encoders continue to emit lowercase hex.
217        let high = if pair[0] == b'+' { 0 } else { nibble(pair[0]) };
218        let low = nibble(pair[1]);
219        if high == u8::MAX || low == u8::MAX {
220            return Err(if std::str::from_utf8(pair).is_err() {
221                "non-ASCII hex".to_owned()
222            } else {
223                "bad hex: invalid digit found in string".to_owned()
224            });
225        }
226        out.push((high << 4) | low);
227    }
228    Ok(out)
229}
230
231/// Driver JSON value → row-codec value for one column. `null`/absent maps to
232/// NULL; bytes travel as `{"$bytes": "<hex>"}`.
233pub fn json_to_column_value(
234    column: &Column,
235    value: Option<&Value>,
236) -> Result<Option<ColumnValue>, String> {
237    let value = match value {
238        None | Some(Value::Null) => return Ok(None),
239        Some(v) => v,
240    };
241    let fail = |expected: &str| {
242        Err(format!(
243            "column {:?}: expected {expected}, got {value}",
244            column.name
245        ))
246    };
247    match column.ty {
248        ColumnType::String => match value.as_str() {
249            Some(s) => Ok(Some(ColumnValue::String(s.to_owned()))),
250            None => fail("a string"),
251        },
252        ColumnType::Integer => match value.as_i64() {
253            Some(i) => Ok(Some(ColumnValue::Integer(i))),
254            None => fail("an integer"),
255        },
256        ColumnType::Float => match value.as_f64() {
257            Some(f) => Ok(Some(ColumnValue::Float(f))),
258            None => fail("a number"),
259        },
260        ColumnType::Boolean => match value.as_bool() {
261            Some(b) => Ok(Some(ColumnValue::Boolean(b))),
262            None => fail("a boolean"),
263        },
264        // `json` columns stay raw strings at the driver boundary (§2.4).
265        ColumnType::Json => match value.as_str() {
266            Some(s) => Ok(Some(ColumnValue::Json(RawJson(s.to_owned())))),
267            None => fail("a raw JSON string"),
268        },
269        // `blob_ref` (tag 7) also stays a raw string at the boundary (§5.9.1).
270        ColumnType::BlobRef => match value.as_str() {
271            Some(s) => Ok(Some(ColumnValue::BlobRef(RawJson(s.to_owned())))),
272            None => fail("a raw BlobRef JSON string"),
273        },
274        ColumnType::Bytes => match value.get("$bytes").and_then(Value::as_str) {
275            Some(hex) => Ok(Some(ColumnValue::Bytes(hex_to_bytes(hex)?))),
276            None => fail("a {\"$bytes\": hex} object"),
277        },
278        // §5.10: crdt bytes cross the boundary as {"$bytes": hex}, like bytes.
279        ColumnType::Crdt => match value.get("$bytes").and_then(Value::as_str) {
280            Some(hex) => Ok(Some(ColumnValue::Crdt(hex_to_bytes(hex)?))),
281            None => fail("a {\"$bytes\": hex} object"),
282        },
283    }
284}
285
286/// Row-codec value → driver JSON value.
287pub fn column_value_to_json(value: &Option<ColumnValue>) -> Value {
288    match value {
289        None => Value::Null,
290        Some(ColumnValue::String(s)) => Value::from(s.clone()),
291        Some(ColumnValue::Integer(i)) => Value::from(*i),
292        Some(ColumnValue::Float(f)) => {
293            serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number)
294        }
295        Some(ColumnValue::Boolean(b)) => Value::from(*b),
296        Some(ColumnValue::Json(raw)) => Value::from(raw.0.clone()),
297        Some(ColumnValue::BlobRef(raw)) => Value::from(raw.0.clone()),
298        Some(ColumnValue::Bytes(bytes)) => {
299            let mut map = Map::new();
300            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
301            Value::Object(map)
302        }
303        Some(ColumnValue::Crdt(bytes)) => {
304            let mut map = Map::new();
305            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
306            Value::Object(map)
307        }
308    }
309}
310
311/// Turn an app-facing value map into a full-row map: every column present,
312/// a missing nullable column filled with NULL, a missing non-nullable column
313/// a loud error (§6.1 full-row payloads, §6.7 `mutate` marks every column).
314pub fn full_row_values(
315    table: &TableSchema,
316    mut values: Map<String, Value>,
317) -> Result<Map<String, Value>, String> {
318    for column in &table.columns {
319        if values.contains_key(&column.name) {
320            continue;
321        }
322        if !column.nullable {
323            return Err(format!(
324                "table {:?}: column {:?} is not nullable (§6.1 full-row payloads)",
325                table.name, column.name
326            ));
327        }
328        values.insert(column.name.clone(), Value::Null);
329    }
330    Ok(values)
331}
332
333/// Encode one full row (driver JSON values keyed by column name) with the
334/// generated row codec (§2.4, §6.1). §5.11: encrypted columns are encrypted
335/// here — the encode-at-send seam — before the codec serializes them as
336/// ciphertext-envelope `bytes` using `wire_columns`.
337pub fn encode_row_json(
338    table: &TableSchema,
339    row_id: &str,
340    values: &Map<String, Value>,
341    encryption: &EncryptionConfig,
342) -> Result<Vec<u8>, String> {
343    // Build the row from the LOCAL (declared-type) columns.
344    let mut row: Row = Vec::with_capacity(table.columns.len());
345    for column in &table.columns {
346        let value = json_to_column_value(column, values.get(&column.name))?;
347        if value.is_none() && !column.nullable {
348            return Err(format!(
349                "table {:?}: column {:?} is not nullable (§6.1 full-row payloads)",
350                table.name, column.name
351            ));
352        }
353        row.push(value);
354    }
355    if table.has_encrypted_columns() {
356        encrypt_row(table, row_id, &mut row, encryption)?;
357    }
358    // Serialize with the WIRE columns (encrypted columns are `bytes`).
359    let mut w = Writer::new();
360    encode_row(&mut w, &table.wire_columns, &row);
361    Ok(w.into_bytes())
362}
363
364/// Encode one SPARSE push payload (§2.4, §6.1): the keys of `values` ARE the
365/// presence set — a column with no key stays absent. §5.11: only present
366/// encrypted columns are encrypted; absent columns are densified to NULL for
367/// key-id selection only. Serialized with the `wire_columns` table.
368pub fn encode_sparse_row_json(
369    table: &TableSchema,
370    row_id: &str,
371    values: &Map<String, Value>,
372    encryption: &EncryptionConfig,
373) -> Result<Vec<u8>, String> {
374    let mut row: SparseRow = Vec::with_capacity(table.columns.len());
375    for column in &table.columns {
376        let Some(raw) = values.get(&column.name) else {
377            row.push(SparseSlot::Absent);
378            continue;
379        };
380        let value = json_to_column_value(column, Some(raw))?;
381        if value.is_none() && !column.nullable {
382            return Err(format!(
383                "table {:?}: column {:?} is not nullable (§6.1)",
384                table.name, column.name
385            ));
386        }
387        row.push(value.map_or(SparseSlot::Null, SparseSlot::Value));
388    }
389    if table.has_encrypted_columns() {
390        encrypt_sparse_row(table, row_id, &mut row, encryption)?;
391    }
392    Ok(encode_sparse_row(&table.wire_columns, table.pk_index, &row))
393}
394
395/// Decode one row-codec payload; trailing bytes are a decode error. §5.11:
396/// encrypted columns are decrypted here — the apply seam — back to their
397/// declared-type plaintext value for the local mirror.
398pub fn decode_row_bytes(
399    table: &TableSchema,
400    payload: &[u8],
401    encryption: &EncryptionConfig,
402) -> Result<Row, String> {
403    let mut r = Reader::new(payload);
404    // Decode with the WIRE columns (encrypted columns arrive as `bytes`).
405    let mut row = decode_row(&mut r, &table.wire_columns).map_err(|e| e.to_string())?;
406    if !r.is_empty() {
407        return Err("row payload has trailing bytes".to_owned());
408    }
409    if table.has_encrypted_columns() {
410        decrypt_row(table, &mut row, encryption)?;
411    }
412    Ok(row)
413}
414
415/// §5.11: decrypt the encrypted columns of an already-decoded segment row
416/// (rows segments decode via their own column table, so decryption is a
417/// post-decode pass over the positional values).
418pub fn decrypt_segment_row(
419    table: &TableSchema,
420    row: &mut Row,
421    encryption: &EncryptionConfig,
422) -> Result<(), String> {
423    decrypt_row(table, row, encryption)
424}
425
426// -- §5.11 encrypt/decrypt seam (feature-gated) ------------------------------
427
428#[cfg(feature = "e2ee")]
429fn encrypt_row(
430    table: &TableSchema,
431    _row_id: &str,
432    row: &mut Row,
433    encryption: &EncryptionConfig,
434) -> Result<(), String> {
435    use rand_core::RngCore;
436    use ssp2::crypto::{encrypt_value, NONCE_LENGTH};
437    for enc in &table.encrypted_columns {
438        let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
439            continue; // NULL stays NULL (§5.11)
440        };
441        let plain = column_value_to_plain(value)?;
442        let key_id = encryption.key_id_for(table, row)?;
443        let key = encryption
444            .keys
445            .get(&key_id)
446            .ok_or_else(|| format!("client.decrypt_failed: no key for keyId {key_id:?}"))?;
447        let mut nonce = [0u8; NONCE_LENGTH];
448        rand_core::OsRng.fill_bytes(&mut nonce);
449        let envelope = encrypt_value(&plain, &key_id, key, nonce)?;
450        row[enc.index] = Some(ColumnValue::Bytes(envelope));
451    }
452    Ok(())
453}
454
455#[cfg(feature = "e2ee")]
456fn encrypt_sparse_row(
457    table: &TableSchema,
458    _row_id: &str,
459    row: &mut SparseRow,
460    encryption: &EncryptionConfig,
461) -> Result<(), String> {
462    use rand_core::RngCore;
463    use ssp2::crypto::{encrypt_value, NONCE_LENGTH};
464    // Densify absent columns to NULL for key-id selection only (§6.1).
465    let dense: Row = row
466        .iter()
467        .map(|slot| match slot {
468            SparseSlot::Value(value) => Some(value.clone()),
469            _ => None,
470        })
471        .collect();
472    let key_id = encryption.key_id_for(table, &dense)?;
473    for enc in &table.encrypted_columns {
474        let Some(SparseSlot::Value(value)) = row.get(enc.index) else {
475            continue; // absent or NULL stays untouched (§5.11)
476        };
477        let plain = column_value_to_plain(value)?;
478        let key = encryption
479            .keys
480            .get(&key_id)
481            .ok_or_else(|| format!("client.decrypt_failed: no key for keyId {key_id:?}"))?;
482        let mut nonce = [0u8; NONCE_LENGTH];
483        rand_core::OsRng.fill_bytes(&mut nonce);
484        let envelope = encrypt_value(&plain, &key_id, key, nonce)?;
485        row[enc.index] = SparseSlot::Value(ColumnValue::Bytes(envelope));
486    }
487    Ok(())
488}
489
490#[cfg(not(feature = "e2ee"))]
491fn encrypt_sparse_row(
492    table: &TableSchema,
493    _row_id: &str,
494    _row: &mut SparseRow,
495    _encryption: &EncryptionConfig,
496) -> Result<(), String> {
497    Err(format!(
498        "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
499        table.name
500    ))
501}
502
503#[cfg(feature = "e2ee")]
504fn decrypt_row(
505    table: &TableSchema,
506    row: &mut Row,
507    encryption: &EncryptionConfig,
508) -> Result<(), String> {
509    use ssp2::crypto::{decrypt_value, DeclaredType};
510    for enc in &table.encrypted_columns {
511        let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
512            continue;
513        };
514        let ColumnValue::Bytes(envelope) = value else {
515            return Err(format!(
516                "client.decrypt_failed: encrypted column at index {} is not bytes",
517                enc.index
518            ));
519        };
520        let declared = DeclaredType::from_name(&enc.declared_type)
521            .ok_or_else(|| format!("unknown declaredType {:?}", enc.declared_type))?;
522        let keys = &encryption.keys;
523        let plain = decrypt_value(declared, envelope, |id| keys.get(id).cloned())
524            .map_err(|e| e.to_string())?;
525        row[enc.index] = Some(plain_to_column_value(plain));
526    }
527    Ok(())
528}
529
530/// Without the `e2ee` feature, a schema with encrypted columns is a
531/// misconfiguration — fail loud rather than ship plaintext.
532#[cfg(not(feature = "e2ee"))]
533fn encrypt_row(
534    table: &TableSchema,
535    _row_id: &str,
536    _row: &mut Row,
537    _encryption: &EncryptionConfig,
538) -> Result<(), String> {
539    Err(format!(
540        "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
541        table.name
542    ))
543}
544
545#[cfg(not(feature = "e2ee"))]
546fn decrypt_row(
547    table: &TableSchema,
548    _row: &mut Row,
549    _encryption: &EncryptionConfig,
550) -> Result<(), String> {
551    Err(format!(
552        "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
553        table.name
554    ))
555}
556
557#[cfg(feature = "e2ee")]
558fn column_value_to_plain(value: &ColumnValue) -> Result<ssp2::crypto::PlainValue, String> {
559    use ssp2::crypto::PlainValue;
560    Ok(match value {
561        ColumnValue::String(s) => PlainValue::String(s.clone()),
562        ColumnValue::Integer(i) => PlainValue::Integer(*i),
563        ColumnValue::Float(f) => PlainValue::Float(*f),
564        ColumnValue::Boolean(b) => PlainValue::Boolean(*b),
565        ColumnValue::Json(j) => PlainValue::Json(j.0.clone()),
566        ColumnValue::BlobRef(j) => PlainValue::BlobRef(j.0.clone()),
567        ColumnValue::Bytes(b) => PlainValue::Bytes(b.clone()),
568        ColumnValue::Crdt(_) => return Err("crdt columns cannot be encrypted (§5.11)".to_owned()),
569    })
570}
571
572#[cfg(feature = "e2ee")]
573fn plain_to_column_value(value: ssp2::crypto::PlainValue) -> ColumnValue {
574    use ssp2::crypto::PlainValue;
575    match value {
576        PlainValue::String(s) => ColumnValue::String(s),
577        PlainValue::Integer(i) => ColumnValue::Integer(i),
578        PlainValue::Float(f) => ColumnValue::Float(f),
579        PlainValue::Boolean(b) => ColumnValue::Boolean(b),
580        PlainValue::Json(s) => ColumnValue::Json(RawJson(s)),
581        PlainValue::BlobRef(s) => ColumnValue::BlobRef(RawJson(s)),
582        PlainValue::Bytes(b) => ColumnValue::Bytes(b),
583    }
584}
585
586/// Render a primary-key value as the wire `rowId` string.
587pub fn render_row_id(value: &Option<ColumnValue>) -> Result<String, String> {
588    match value {
589        Some(ColumnValue::String(s)) => Ok(s.clone()),
590        Some(ColumnValue::Integer(i)) => Ok(i.to_string()),
591        Some(ColumnValue::Float(f)) => Ok(f.to_string()),
592        Some(ColumnValue::Boolean(b)) => Ok(b.to_string()),
593        Some(ColumnValue::Json(raw)) => Ok(raw.0.clone()),
594        Some(ColumnValue::BlobRef(_)) => Err("blob_ref column cannot be a rowId".to_owned()),
595        Some(ColumnValue::Bytes(_)) => Err("bytes column cannot be a rowId".to_owned()),
596        Some(ColumnValue::Crdt(_)) => Err("crdt column cannot be a rowId".to_owned()),
597        None => Err("primary key value is missing".to_owned()),
598    }
599}
600
601/// Driver JSON value → wire `rowId` string (for optimistic upserts).
602pub fn render_row_id_json(value: Option<&Value>) -> Result<String, String> {
603    match value {
604        Some(Value::String(s)) => Ok(s.clone()),
605        Some(Value::Number(n)) => Ok(n.to_string()),
606        Some(Value::Bool(b)) => Ok(b.to_string()),
607        _ => Err("primary key value is missing or not renderable".to_owned()),
608    }
609}
610
611fn sort_utf16(values: &mut [String]) {
612    values.sort_by(|a, b| {
613        if utf16_lt(a, b) {
614            std::cmp::Ordering::Less
615        } else if utf16_lt(b, a) {
616            std::cmp::Ordering::Greater
617        } else {
618            std::cmp::Ordering::Equal
619        }
620    });
621}
622
623/// Sort scope-map keys into ascending code-unit order (the canonical `map`
624/// encoding of the Conventions section).
625pub fn sort_scope_map(map: &mut [(String, Vec<String>)]) {
626    map.sort_by(|a, b| {
627        if utf16_lt(&a.0, &b.0) {
628            std::cmp::Ordering::Less
629        } else if utf16_lt(&b.0, &a.0) {
630            std::cmp::Ordering::Greater
631        } else {
632            std::cmp::Ordering::Equal
633        }
634    });
635}
636
637/// §11.2 canonical JSON of a scope map: keys sorted by code-unit, value
638/// lists sorted and deduplicated, no insignificant whitespace.
639pub fn canonical_scope_json(scopes: &[(String, Vec<String>)]) -> String {
640    let mut entries: Vec<(String, Vec<String>)> = scopes.to_vec();
641    sort_scope_map(&mut entries);
642    let mut out = String::from("{");
643    for (i, (key, values)) in entries.iter().enumerate() {
644        if i > 0 {
645            out.push(',');
646        }
647        let mut sorted = values.clone();
648        sort_utf16(&mut sorted);
649        sorted.dedup();
650        out.push_str(&serde_json::to_string(key).expect("string serializes"));
651        out.push_str(":[");
652        for (j, value) in sorted.iter().enumerate() {
653            if j > 0 {
654                out.push(',');
655            }
656            out.push_str(&serde_json::to_string(value).expect("string serializes"));
657        }
658        out.push(']');
659    }
660    out.push('}');
661    out
662}
663
664/// Scope map as a driver JSON object (`variable → list of values`, §3.2).
665pub fn scope_map_to_json(scopes: &[(String, Vec<String>)]) -> Value {
666    let mut map = Map::new();
667    for (key, values) in scopes {
668        map.insert(
669            key.clone(),
670            Value::Array(values.iter().map(|v| Value::from(v.clone())).collect()),
671        );
672    }
673    Value::Object(map)
674}
675
676/// Driver JSON object → scope map, preserving key order.
677pub fn json_to_scope_map(value: &Value) -> Result<Vec<(String, Vec<String>)>, String> {
678    let object = value
679        .as_object()
680        .ok_or_else(|| "scope map must be an object".to_owned())?;
681    let mut out = Vec::with_capacity(object.len());
682    for (key, values) in object {
683        let list = values
684            .as_array()
685            .ok_or_else(|| format!("scope values for {key:?} must be a list (§0)"))?;
686        let mut strings = Vec::with_capacity(list.len());
687        for v in list {
688            strings.push(
689                v.as_str()
690                    .ok_or_else(|| format!("scope value for {key:?} is not a string"))?
691                    .to_owned(),
692            );
693        }
694        out.push((key.clone(), strings));
695    }
696    Ok(out)
697}
698
699#[cfg(test)]
700mod naming_tests {
701    use serde_json::{json, Map, Value};
702    use ssp2::segment::{Column, ColumnType, ColumnValue, Row};
703
704    use super::{
705        bytes_to_hex, hex_to_bytes, normalize_values_casing, snake_to_camel, EncryptionConfig,
706    };
707    use crate::schema::{EncryptedColumn, TableSchema};
708
709    #[test]
710    fn byte_envelopes_preserve_canonical_hex_for_every_byte() {
711        let bytes: Vec<u8> = (0..=255).collect();
712        let expected: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
713        assert_eq!(bytes_to_hex(&bytes), expected);
714        assert_eq!(hex_to_bytes(&expected).expect("decode"), bytes);
715        assert_eq!(bytes_to_hex(&[]), "");
716        assert_eq!(bytes_to_hex(&[0, 15, 16, 255]), "000f10ff");
717        let large = bytes.repeat(8192);
718        assert_eq!(bytes_to_hex(&large), expected.repeat(8192));
719        assert_eq!(
720            hex_to_bytes(&bytes_to_hex(&large)).expect("large decode"),
721            large
722        );
723    }
724
725    #[test]
726    fn byte_decoder_preserves_radix_parser_inputs_and_errors() {
727        for first in 0_u8..=127 {
728            for second in 0_u8..=127 {
729                let pair = [first, second];
730                let input = std::str::from_utf8(&pair).unwrap();
731                let expected = u8::from_str_radix(input, 16)
732                    .map(|byte| vec![byte])
733                    .map_err(|error| format!("bad hex: {error}"));
734                assert_eq!(hex_to_bytes(input), expected, "ASCII pair {pair:?}");
735            }
736        }
737        assert_eq!(hex_to_bytes("+fA0+100"), Ok(vec![15, 160, 1, 0]));
738        for input in [
739            "", "0", "abc", "é", "雪", "😀", "0é0", "é00", "00é", "00😀", "😀00", "\0\0",
740        ] {
741            let expected = if !input.len().is_multiple_of(2) {
742                Err("odd-length hex string".to_owned())
743            } else {
744                input
745                    .as_bytes()
746                    .chunks(2)
747                    .map(|pair| {
748                        let text =
749                            std::str::from_utf8(pair).map_err(|_| "non-ASCII hex".to_owned())?;
750                        u8::from_str_radix(text, 16).map_err(|error| format!("bad hex: {error}"))
751                    })
752                    .collect::<Result<Vec<_>, _>>()
753            };
754            assert_eq!(hex_to_bytes(input), expected, "input {input:?}");
755        }
756    }
757
758    #[test]
759    fn snake_to_camel_pinned_vectors() {
760        for (input, expected) in [
761            ("created_at", "createdAt"),
762            ("col_2", "col2"),
763            ("user_id", "userId"),
764            ("_internal", "_internal"),
765            ("__foo_bar", "__fooBar"),
766            ("row_", "row_"),
767            ("id_url", "idUrl"),
768            ("api_key", "apiKey"),
769            ("title", "title"),
770            ("alreadyCamel", "alreadyCamel"),
771            ("a__b", "aB"),
772            ("_lead_and_trail_", "_leadAndTrail_"),
773            ("count(*)", "count(*)"),
774        ] {
775            assert_eq!(snake_to_camel(input), expected, "input {input:?}");
776        }
777    }
778
779    #[test]
780    fn portable_key_selector_reads_a_non_encrypted_string_column() {
781        let columns = vec![
782            Column {
783                name: "id".to_owned(),
784                ty: ColumnType::String,
785                nullable: false,
786            },
787            Column {
788                name: "encryption_key_id".to_owned(),
789                ty: ColumnType::String,
790                nullable: false,
791            },
792            Column {
793                name: "note".to_owned(),
794                ty: ColumnType::String,
795                nullable: false,
796            },
797        ];
798        let table = TableSchema {
799            name: "patients".to_owned(),
800            columns: columns.clone(),
801            wire_columns: columns,
802            primary_key: "id".to_owned(),
803            pk_index: 0,
804            scope_variables: Vec::new(),
805            indexes: Vec::new(),
806            fts_indexes: Vec::new(),
807            encrypted_columns: vec![EncryptedColumn {
808                index: 2,
809                declared_type: "string".to_owned(),
810            }],
811        };
812        let mut config = EncryptionConfig::default();
813        config
814            .key_id_columns
815            .insert("patients".to_owned(), "encryption_key_id".to_owned());
816        let row: Row = vec![
817            Some(ColumnValue::String("patient-1".to_owned())),
818            Some(ColumnValue::String("practice-key-v1".to_owned())),
819            Some(ColumnValue::String("Identity".to_owned())),
820        ];
821        assert_eq!(
822            config.key_id_for(&table, &row).expect("selects key"),
823            "practice-key-v1"
824        );
825    }
826
827    #[test]
828    fn debug_render_redacts_key_material() {
829        let mut config = EncryptionConfig::default();
830        config
831            .keys
832            .insert("practice-key-v1".to_owned(), vec![0xAB; 32]);
833        config
834            .key_id_columns
835            .insert("patients".to_owned(), "encryption_key_id".to_owned());
836        let rendered = format!("{config:?}");
837        assert!(rendered.contains("practice-key-v1"), "{rendered}");
838        assert!(rendered.contains("<32 bytes>"), "{rendered}");
839        assert!(rendered.contains("encryption_key_id"), "{rendered}");
840        // Raw byte values never appear (0xAB renders as 171 under derive).
841        assert!(!rendered.contains("171"), "{rendered}");
842        assert!(!rendered.contains("0xAB"), "{rendered}");
843    }
844
845    fn table(names: &[&str]) -> TableSchema {
846        let columns: Vec<Column> = names
847            .iter()
848            .map(|n| Column {
849                name: (*n).to_owned(),
850                ty: ColumnType::String,
851                nullable: true,
852            })
853            .collect();
854        TableSchema {
855            name: "t".to_owned(),
856            columns: columns.clone(),
857            wire_columns: columns,
858            primary_key: "id".to_owned(),
859            pk_index: 0,
860            scope_variables: Vec::new(),
861            indexes: Vec::new(),
862            fts_indexes: Vec::new(),
863            encrypted_columns: Vec::new(),
864        }
865    }
866
867    fn map(entries: &[(&str, &str)]) -> Map<String, Value> {
868        entries
869            .iter()
870            .map(|(k, v)| ((*k).to_owned(), json!(v)))
871            .collect()
872    }
873
874    #[test]
875    fn camel_keys_normalize_to_sql_names() {
876        let t = table(&["id", "list_id", "updated_at_ms"]);
877        let out = normalize_values_casing(
878            &t,
879            map(&[("id", "x"), ("listId", "l"), ("updatedAtMs", "9")]),
880        )
881        .expect("normalizes");
882        assert_eq!(out.get("list_id"), Some(&json!("l")));
883        assert_eq!(out.get("updated_at_ms"), Some(&json!("9")));
884        assert!(!out.contains_key("listId"));
885    }
886
887    #[test]
888    fn snake_keys_pass_through() {
889        let t = table(&["id", "list_id"]);
890        let out = normalize_values_casing(&t, map(&[("id", "x"), ("list_id", "l")])).expect("ok");
891        assert_eq!(out.get("list_id"), Some(&json!("l")));
892    }
893
894    #[test]
895    fn both_casings_for_one_column_is_an_error() {
896        let t = table(&["id", "list_id"]);
897        let err = normalize_values_casing(&t, map(&[("list_id", "a"), ("listId", "b")]))
898            .expect_err("rejects");
899        assert!(err.contains("both snake_case and camelCase"), "{err}");
900    }
901
902    #[test]
903    fn an_alias_colliding_with_a_real_column_never_steals_it() {
904        // `col_2` camel-maps to `col2`, which IS a column: exact wins, no rename.
905        let t = table(&["id", "col_2", "col2"]);
906        let out = normalize_values_casing(&t, map(&[("id", "x"), ("col2", "v")])).expect("ok");
907        assert_eq!(out.get("col2"), Some(&json!("v")));
908        assert!(!out.contains_key("col_2"));
909    }
910
911    #[test]
912    fn unknown_and_internal_columns_fail_loud() {
913        let t = table(&["id", "title"]);
914        let unknown = normalize_values_casing(&t, map(&[("id", "x"), ("typo", "v")]))
915            .expect_err("unknown field");
916        assert!(unknown.contains("unknown column"), "{unknown}");
917        let internal = normalize_values_casing(&t, map(&[("id", "x"), ("_sync_version", "1")]))
918            .expect_err("internal field");
919        assert!(internal.contains("internal sync column"), "{internal}");
920    }
921}