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