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};
13
14use crate::schema::TableSchema;
15
16/// §5.11 client-side encryption keys (`keyId → 32-byte key`) plus optional
17/// key selection. Empty ⇒ E2EE off. `key_id_for` defaults to per-table
18/// (`keyId = table`). Always present on the client; the crypto is compiled
19/// only under the `e2ee` feature.
20#[derive(Clone, Default)]
21pub struct EncryptionConfig {
22    pub keys: HashMap<String, Vec<u8>>,
23    /// Portable per-table selector: the named non-encrypted string column in
24    /// each plaintext row contains the key id used for new envelopes.
25    pub key_id_columns: HashMap<String, String>,
26}
27
28/// Manual Debug so a stray `{config:?}` log line stays free of key material:
29/// key entries render as `id → <N bytes>`.
30impl std::fmt::Debug for EncryptionConfig {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        struct Redacted(usize);
33        impl std::fmt::Debug for Redacted {
34            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35                write!(f, "<{} bytes>", self.0)
36            }
37        }
38        let keys: std::collections::BTreeMap<&String, Redacted> = self
39            .keys
40            .iter()
41            .map(|(id, key)| (id, Redacted(key.len())))
42            .collect();
43        f.debug_struct("EncryptionConfig")
44            .field("keys", &keys)
45            .field("key_id_columns", &self.key_id_columns)
46            .finish()
47    }
48}
49
50impl Drop for EncryptionConfig {
51    fn drop(&mut self) {
52        // Best-effort native key hygiene: replacement, preflight entry, and
53        // client shutdown overwrite every owned key buffer before release.
54        for key in self.keys.values_mut() {
55            key.fill(0);
56        }
57    }
58}
59
60impl EncryptionConfig {
61    pub fn is_empty(&self) -> bool {
62        self.keys.is_empty()
63    }
64
65    /// §5.11 portable key selection: configured row column, then table name.
66    /// Encode-seam failures carry `client.encrypt_failed` (never the apply-
67    /// seam `client.decrypt_failed`). The caller supplies the presence set
68    /// first with the stored local row merged into an absent selector.
69    pub fn key_id_for(&self, table: &TableSchema, row: &Row) -> Result<String, String> {
70        let Some(column_name) = self.key_id_columns.get(&table.name) else {
71            return Ok(table.name.clone());
72        };
73        let Some(index) = table
74            .columns
75            .iter()
76            .position(|column| &column.name == column_name)
77        else {
78            return Err(format!(
79                "client.encrypt_failed: encryption key-id column {column_name:?} is not present on table {:?}",
80                table.name
81            ));
82        };
83        if table
84            .encrypted_columns
85            .iter()
86            .any(|column| column.index == index)
87        {
88            return Err(format!(
89                "client.encrypt_failed: encryption key-id column {column_name:?} on table {:?} must not be encrypted",
90                table.name
91            ));
92        }
93        match row.get(index).and_then(|value| value.as_ref()) {
94            Some(ColumnValue::String(key_id)) if !key_id.is_empty() => Ok(key_id.clone()),
95            _ => Err(format!(
96                "client.encrypt_failed: encryption key-id column {column_name:?} on table {:?} must contain a non-empty string",
97                table.name
98            )),
99        }
100    }
101}
102
103/// The pinned snake→camel conversion. The Rust
104/// copy of the typegen/TS-client algorithm, kept in lockstep by shared test
105/// vectors. Leading/trailing `_` runs are preserved; middle segments split
106/// on `_` (doubled underscores drop); no acronym awareness.
107pub fn snake_to_camel(name: &str) -> String {
108    let is_mappable = {
109        let bare = name.trim_start_matches('_');
110        !bare.is_empty()
111            && bare.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
112            && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
113    };
114    if !is_mappable {
115        return name.to_owned();
116    }
117    let lead_len = name.len() - name.trim_start_matches('_').len();
118    let (lead, bare) = name.split_at(lead_len);
119    let trail_len = bare.len() - bare.trim_end_matches('_').len();
120    let (middle, trail) = bare.split_at(bare.len() - trail_len);
121    let mut segments = middle.split('_').filter(|s| !s.is_empty());
122    let Some(first) = segments.next() else {
123        return name.to_owned();
124    };
125    let mut out = String::with_capacity(name.len());
126    out.push_str(lead);
127    out.push_str(first);
128    for segment in segments {
129        let mut chars = segment.chars();
130        if let Some(head) = chars.next() {
131            out.push(head.to_ascii_uppercase());
132            out.push_str(chars.as_str());
133        }
134    }
135    out.push_str(trail);
136    out
137}
138
139/// §5 mutate key normalization: accept BOTH casings for upsert value keys —
140/// the SQL-truth snake_case and the generated row types' camelCase. A camel
141/// key renames to its column's SQL name when that is unambiguous (the alias
142/// equals no other column's exact name, and no two columns share it). A
143/// column given in both casings is an error. Unknown keys fail loud, matching
144/// the TypeScript core; silently dropping an app field would corrupt a
145/// full-row mutation while appearing successful.
146pub fn normalize_values_casing(
147    table: &TableSchema,
148    mut values: Map<String, Value>,
149) -> Result<Map<String, Value>, String> {
150    for column in &table.columns {
151        let camel = snake_to_camel(&column.name);
152        if camel == column.name {
153            continue;
154        }
155        // Exact names always win; an alias colliding with another column's
156        // real name (or with another column's alias) is not an alias.
157        if table.columns.iter().any(|c| c.name == camel) {
158            continue;
159        }
160        if table
161            .columns
162            .iter()
163            .filter(|c| snake_to_camel(&c.name) == camel)
164            .count()
165            > 1
166        {
167            continue;
168        }
169        if let Some(value) = values.remove(&camel) {
170            if values.contains_key(&column.name) {
171                return Err(format!(
172                    "sync.invalid_request: table {:?}: column {:?} appears twice in mutation values (as both snake_case and camelCase) — pass it once",
173                    table.name, column.name
174                ));
175            }
176            values.insert(column.name.clone(), value);
177        }
178    }
179    for key in values.keys() {
180        if !table.columns.iter().any(|column| column.name == *key) {
181            if key.starts_with("_sync_") {
182                return Err(format!(
183                    "sync.invalid_request: table {:?}: {:?} is an internal sync column and cannot appear in mutation values",
184                    table.name, key
185                ));
186            }
187            return Err(format!(
188                "sync.invalid_request: table {:?}: unknown column {:?} in mutation values (snake_case and camelCase keys are accepted)",
189                table.name, key
190            ));
191        }
192    }
193    Ok(values)
194}
195
196pub fn bytes_to_hex(bytes: &[u8]) -> String {
197    let mut out = String::with_capacity(bytes.len() * 2);
198    for b in bytes {
199        out.push(char::from(b"0123456789abcdef"[usize::from(b >> 4)]));
200        out.push(char::from(b"0123456789abcdef"[usize::from(b & 15)]));
201    }
202    out
203}
204
205pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
206    if !hex.len().is_multiple_of(2) {
207        return Err("odd-length hex string".to_owned());
208    }
209    let mut out = Vec::with_capacity(hex.len() / 2);
210    let nibble = |byte| match byte {
211        b'0'..=b'9' => byte - b'0',
212        b'a'..=b'f' => byte - b'a' + 10,
213        b'A'..=b'F' => byte - b'A' + 10,
214        _ => u8::MAX,
215    };
216    for pair in hex.as_bytes().as_chunks::<2>().0 {
217        // Preserve the prior radix parser's accepted +digit pair as well as
218        // uppercase input. Canonical encoders continue to emit lowercase hex.
219        let high = if pair[0] == b'+' { 0 } else { nibble(pair[0]) };
220        let low = nibble(pair[1]);
221        if high == u8::MAX || low == u8::MAX {
222            return Err(if std::str::from_utf8(pair).is_err() {
223                "non-ASCII hex".to_owned()
224            } else {
225                "bad hex: invalid digit found in string".to_owned()
226            });
227        }
228        out.push((high << 4) | low);
229    }
230    Ok(out)
231}
232
233/// Driver JSON value → row-codec value for one column. `null`/absent maps to
234/// NULL; bytes travel as `{"$bytes": "<hex>"}`.
235pub fn json_to_column_value(
236    column: &Column,
237    value: Option<&Value>,
238) -> Result<Option<ColumnValue>, String> {
239    let value = match value {
240        None | Some(Value::Null) => return Ok(None),
241        Some(v) => v,
242    };
243    let fail = |expected: &str| {
244        Err(format!(
245            "column {:?}: expected {expected}, got {value}",
246            column.name
247        ))
248    };
249    match column.ty {
250        ColumnType::String => match value.as_str() {
251            Some(s) => Ok(Some(ColumnValue::String(s.to_owned()))),
252            None => fail("a string"),
253        },
254        ColumnType::Integer => match value.as_i64() {
255            Some(i)
256                if (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
257                    .contains(&i) =>
258            {
259                Ok(Some(ColumnValue::Integer(i)))
260            }
261            _ => fail("an integer within the JavaScript safe-integer range"),
262        },
263        ColumnType::Float => match value.as_f64() {
264            Some(f) => Ok(Some(ColumnValue::Float(f))),
265            None => fail("a number"),
266        },
267        ColumnType::Boolean => match value.as_bool() {
268            Some(b) => Ok(Some(ColumnValue::Boolean(b))),
269            None => fail("a boolean"),
270        },
271        // `json` columns stay raw strings at the driver boundary (§2.4).
272        ColumnType::Json => match value.as_str() {
273            Some(s) => Ok(Some(ColumnValue::Json(RawJson(s.to_owned())))),
274            None => fail("a raw JSON string"),
275        },
276        // `blob_ref` (tag 7) also stays a raw string at the boundary (§5.9.1).
277        ColumnType::BlobRef => match value.as_str() {
278            Some(s) => Ok(Some(ColumnValue::BlobRef(RawJson(s.to_owned())))),
279            None => fail("a raw BlobRef JSON string"),
280        },
281        ColumnType::Bytes => match value.get("$bytes").and_then(Value::as_str) {
282            Some(hex) => Ok(Some(ColumnValue::Bytes(hex_to_bytes(hex)?))),
283            None => fail("a {\"$bytes\": hex} object"),
284        },
285        // §5.10: crdt bytes cross the boundary as {"$bytes": hex}, like bytes.
286        ColumnType::Crdt => match value.get("$bytes").and_then(Value::as_str) {
287            Some(hex) => Ok(Some(ColumnValue::Crdt(hex_to_bytes(hex)?))),
288            None => fail("a {\"$bytes\": hex} object"),
289        },
290    }
291}
292
293/// Row-codec value → driver JSON value.
294pub fn column_value_to_json(value: &Option<ColumnValue>) -> Value {
295    match value {
296        None => Value::Null,
297        Some(ColumnValue::String(s)) => Value::from(s.clone()),
298        Some(ColumnValue::Integer(i)) => Value::from(*i),
299        Some(ColumnValue::Float(f)) => {
300            serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number)
301        }
302        Some(ColumnValue::Boolean(b)) => Value::from(*b),
303        Some(ColumnValue::Json(raw)) => Value::from(raw.0.clone()),
304        Some(ColumnValue::BlobRef(raw)) => Value::from(raw.0.clone()),
305        Some(ColumnValue::Bytes(bytes)) => {
306            let mut map = Map::new();
307            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
308            Value::Object(map)
309        }
310        Some(ColumnValue::Crdt(bytes)) => {
311            let mut map = Map::new();
312            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
313            Value::Object(map)
314        }
315    }
316}
317
318/// Turn an app-facing value map into a full-row map: every column present,
319/// a missing nullable column filled with NULL, a missing non-nullable column
320/// a loud error (§6.1 full-row payloads, §6.7 `mutate` marks every column).
321pub fn full_row_values(
322    table: &TableSchema,
323    mut values: Map<String, Value>,
324) -> Result<Map<String, Value>, String> {
325    for column in &table.columns {
326        if values.contains_key(&column.name) {
327            continue;
328        }
329        if !column.nullable {
330            return Err(format!(
331                "sync.invalid_request: table {:?}: column {:?} is not nullable (§6.1 full-row payloads)",
332                table.name, column.name
333            ));
334        }
335        values.insert(column.name.clone(), Value::Null);
336    }
337    Ok(values)
338}
339
340/// §7.1: exactly one `$bytes` key holding an even-length hexadecimal string.
341/// Uppercase digits are accepted; encoders emit lowercase.
342pub(crate) fn is_canonical_bytes_envelope(value: &Value) -> bool {
343    value
344        .as_object()
345        .filter(|map| map.len() == 1)
346        .and_then(|map| map.get("$bytes"))
347        .and_then(Value::as_str)
348        .is_some_and(|hex| {
349            hex.len().is_multiple_of(2) && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
350        })
351}
352
353/// §7.1: a `bytes`/`crdt` column crosses the authoring boundary only as exactly
354/// one `$bytes` key holding an even-length hexadecimal string. The shared
355/// decoder keeps its tolerance for persisted payloads (§7.4.4 replay), so the
356/// canonical rule is enforced at the encode seams instead. `None` for every
357/// other column type and for a canonical envelope.
358fn author_bytes_envelope_error(column: &Column, value: &Value) -> Option<String> {
359    // An explicit `null` stays with `json_to_column_value`: a nullable column
360    // accepts it and a non-nullable one is classified by the required-column
361    // check, not by the envelope rule.
362    if value.is_null() || !matches!(column.ty, ColumnType::Bytes | ColumnType::Crdt) {
363        return None;
364    }
365    if is_canonical_bytes_envelope(value) {
366        return None;
367    }
368    Some(format!(
369        "sync.invalid_request: column {:?}: expected exactly {{\"$bytes\": hex}} with an even number of hexadecimal digits, got {value}",
370        column.name
371    ))
372}
373
374/// Encode one full row (driver JSON values keyed by column name) with the
375/// generated row codec (§2.4, §6.1). §5.11: encrypted columns are encrypted
376/// here — the encode-at-send seam — before the codec serializes them as
377/// ciphertext-envelope `bytes` using `wire_columns`.
378pub fn encode_row_json(
379    table: &TableSchema,
380    row_id: &str,
381    values: &Map<String, Value>,
382    encryption: &EncryptionConfig,
383) -> Result<Vec<u8>, String> {
384    // Build the row from the LOCAL (declared-type) columns.
385    let mut row: Row = Vec::with_capacity(table.columns.len());
386    for column in &table.columns {
387        if let Some(raw) = values.get(&column.name) {
388            if let Some(error) = author_bytes_envelope_error(column, raw) {
389                return Err(error);
390            }
391        }
392        // §6.1/§7.1: a caller value the codec rejects is an invalid request,
393        // never an internal authoring failure; the dynamic cause stays in
394        // `details.legacyCause` at the `ClientError` boundary.
395        let value = json_to_column_value(column, values.get(&column.name))
396            .map_err(|error| format!("sync.invalid_request: {error}"))?;
397        if value.is_none() && !column.nullable {
398            return Err(format!(
399                "sync.invalid_request: table {:?}: column {:?} is not nullable (§6.1 full-row payloads)",
400                table.name, column.name
401            ));
402        }
403        row.push(value);
404    }
405    if table.has_encrypted_columns() {
406        encrypt_row(table, row_id, &mut row, encryption)?;
407    }
408    // Serialize with the WIRE columns (encrypted columns are `bytes`).
409    let mut w = Writer::new();
410    encode_row(&mut w, &table.wire_columns, &row);
411    Ok(w.into_bytes())
412}
413
414/// Encode one SPARSE push payload (§2.4, §6.1): the keys of `values` ARE the
415/// presence set — a column with no key stays absent. §5.11: only present
416/// encrypted columns are encrypted, and key resolution runs only then;
417/// absent selector slots fall back to `fallback` (the stored local row).
418/// Serialized with the `wire_columns` table.
419pub fn encode_sparse_row_json(
420    table: &TableSchema,
421    row_id: &str,
422    values: &Map<String, Value>,
423    encryption: &EncryptionConfig,
424    fallback: Option<&Row>,
425) -> Result<Vec<u8>, String> {
426    let mut row: SparseRow = Vec::with_capacity(table.columns.len());
427    for column in &table.columns {
428        let Some(raw) = values.get(&column.name) else {
429            row.push(SparseSlot::Absent);
430            continue;
431        };
432        if let Some(error) = author_bytes_envelope_error(column, raw) {
433            return Err(error);
434        }
435        // §2.4/§7.1: the push admission values the server rejects. A float
436        // read from JSON is always finite.
437        let refused = match (column.ty, raw) {
438            (ColumnType::String, Value::String(text)) => text.contains('\0'),
439            (ColumnType::Json, Value::String(text)) => {
440                ssp2::primitives::parse_json_document(text, "json value").is_err()
441            }
442            (ColumnType::BlobRef, Value::String(text)) => {
443                ssp2::blob_ref::validate_blob_ref(text).is_err()
444            }
445            _ => false,
446        };
447        if refused {
448            return Err(format!(
449                "sync.invalid_request: column {:?}: the value is not a push admission value (§2.4)",
450                column.name
451            ));
452        }
453        let value = json_to_column_value(column, Some(raw))
454            .map_err(|error| format!("sync.invalid_request: {error}"))?;
455        if value.is_none() && !column.nullable {
456            return Err(format!(
457                "sync.invalid_request: table {:?}: column {:?} is not nullable (§6.1)",
458                table.name, column.name
459            ));
460        }
461        row.push(value.map_or(SparseSlot::Null, SparseSlot::Value));
462    }
463    if table.has_encrypted_columns() {
464        encrypt_sparse_row(table, row_id, &mut row, encryption, fallback)?;
465    }
466    Ok(encode_sparse_row(&table.wire_columns, table.pk_index, &row))
467}
468
469/// Decode one row-codec payload; trailing bytes are a decode error. §5.11:
470/// encrypted columns are decrypted here — the apply seam — back to their
471/// declared-type plaintext value for the local mirror.
472pub fn decode_row_bytes(
473    table: &TableSchema,
474    payload: &[u8],
475    encryption: &EncryptionConfig,
476) -> Result<Row, String> {
477    let mut r = Reader::new(payload);
478    // Decode with the WIRE columns (encrypted columns arrive as `bytes`).
479    let mut row = decode_row(&mut r, &table.wire_columns).map_err(|e| e.to_string())?;
480    if !r.is_empty() {
481        return Err("row payload has trailing bytes".to_owned());
482    }
483    if table.has_encrypted_columns() {
484        decrypt_row(table, &mut row, encryption).map_err(|message| {
485            if message.starts_with("client.decrypt_failed") {
486                message
487            } else {
488                format!("client.decrypt_failed: {message}")
489            }
490        })?;
491    }
492    Ok(row)
493}
494
495/// §5.11: decrypt the encrypted columns of an already-decoded segment row
496/// (rows segments decode via their own column table, so decryption is a
497/// post-decode pass over the positional values).
498pub fn decrypt_segment_row(
499    table: &TableSchema,
500    row: &mut Row,
501    encryption: &EncryptionConfig,
502) -> Result<(), String> {
503    decrypt_row(table, row, encryption)
504}
505
506// -- §5.11 encrypt/decrypt seam (feature-gated) ------------------------------
507
508#[cfg(feature = "e2ee")]
509fn encrypt_row(
510    table: &TableSchema,
511    _row_id: &str,
512    row: &mut Row,
513    encryption: &EncryptionConfig,
514) -> Result<(), String> {
515    use rand_core::RngCore;
516    use ssp2::crypto::{encrypt_value, NONCE_LENGTH};
517    for enc in &table.encrypted_columns {
518        let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
519            continue; // NULL stays NULL (§5.11)
520        };
521        let plain = column_value_to_plain(value)?;
522        let key_id = encryption.key_id_for(table, row)?;
523        let key = encryption
524            .keys
525            .get(&key_id)
526            .ok_or_else(|| format!("client.encrypt_failed: no key for keyId {key_id:?}"))?;
527        let mut nonce = [0u8; NONCE_LENGTH];
528        rand_core::OsRng.fill_bytes(&mut nonce);
529        let envelope = encrypt_value(&plain, &key_id, key, nonce)?;
530        row[enc.index] = Some(ColumnValue::Bytes(envelope));
531    }
532    Ok(())
533}
534
535#[cfg(feature = "e2ee")]
536fn encrypt_sparse_row(
537    table: &TableSchema,
538    _row_id: &str,
539    row: &mut SparseRow,
540    encryption: &EncryptionConfig,
541    fallback: Option<&Row>,
542) -> Result<(), String> {
543    use rand_core::RngCore;
544    use ssp2::crypto::{encrypt_value, NONCE_LENGTH};
545    // Sparse semantics: absent and NULL columns are never re-encrypted, so
546    // a patch presenting no encrypted value needs no key (§5.11).
547    let has_present_encrypted = table
548        .encrypted_columns
549        .iter()
550        .any(|enc| matches!(row.get(enc.index), Some(SparseSlot::Value(_))));
551    if !has_present_encrypted {
552        return Ok(());
553    }
554    // Densify absent columns to NULL for key-id selection only; an absent
555    // selector falls back to the stored local row. Absent ≠ NULL (§6.1):
556    // only an ABSENT slot reads the fallback, a present NULL never does.
557    let mut dense: Row = row
558        .iter()
559        .map(|slot| match slot {
560            SparseSlot::Value(value) => Some(value.clone()),
561            _ => None,
562        })
563        .collect();
564    if let Some(selector) = encryption.key_id_columns.get(&table.name) {
565        if let Some(index) = table
566            .columns
567            .iter()
568            .position(|column| &column.name == selector)
569        {
570            if matches!(row.get(index), None | Some(SparseSlot::Absent)) {
571                let stored: Option<&str> = match fallback
572                    .and_then(|row| row.get(index))
573                    .and_then(|value| value.as_ref())
574                {
575                    Some(ColumnValue::String(key_id)) => Some(key_id),
576                    _ => None,
577                };
578                if let Some(key_id) = stored.filter(|key_id| !key_id.is_empty()) {
579                    dense[index] = Some(ColumnValue::String(key_id.to_owned()));
580                }
581            }
582        }
583    }
584    let key_id = encryption.key_id_for(table, &dense)?;
585    for enc in &table.encrypted_columns {
586        let Some(SparseSlot::Value(value)) = row.get(enc.index) else {
587            continue; // absent or NULL stays untouched (§5.11)
588        };
589        let plain = column_value_to_plain(value)?;
590        let key = encryption
591            .keys
592            .get(&key_id)
593            .ok_or_else(|| format!("client.encrypt_failed: no key for keyId {key_id:?}"))?;
594        let mut nonce = [0u8; NONCE_LENGTH];
595        rand_core::OsRng.fill_bytes(&mut nonce);
596        let envelope = encrypt_value(&plain, &key_id, key, nonce)?;
597        row[enc.index] = SparseSlot::Value(ColumnValue::Bytes(envelope));
598    }
599    Ok(())
600}
601
602#[cfg(not(feature = "e2ee"))]
603fn encrypt_sparse_row(
604    table: &TableSchema,
605    _row_id: &str,
606    row: &mut SparseRow,
607    _encryption: &EncryptionConfig,
608    _fallback: Option<&Row>,
609) -> Result<(), String> {
610    if !table
611        .encrypted_columns
612        .iter()
613        .any(|enc| matches!(row.get(enc.index), Some(SparseSlot::Value(_))))
614    {
615        return Ok(());
616    }
617    Err(format!(
618        "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
619        table.name
620    ))
621}
622
623#[cfg(feature = "e2ee")]
624fn decrypt_row(
625    table: &TableSchema,
626    row: &mut Row,
627    encryption: &EncryptionConfig,
628) -> Result<(), String> {
629    use ssp2::crypto::{decrypt_value, DeclaredType};
630    for enc in &table.encrypted_columns {
631        let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
632            continue;
633        };
634        let ColumnValue::Bytes(envelope) = value else {
635            return Err(format!(
636                "client.decrypt_failed: encrypted column at index {} is not bytes",
637                enc.index
638            ));
639        };
640        let declared = DeclaredType::from_name(&enc.declared_type)
641            .ok_or_else(|| format!("unknown declaredType {:?}", enc.declared_type))?;
642        let keys = &encryption.keys;
643        let plain = decrypt_value(declared, envelope, |id| keys.get(id).cloned())
644            .map_err(|e| e.to_string())?;
645        row[enc.index] = Some(plain_to_column_value(plain));
646    }
647    Ok(())
648}
649
650/// Without the `e2ee` feature, a schema with encrypted columns is a
651/// misconfiguration — fail loud rather than ship plaintext.
652#[cfg(not(feature = "e2ee"))]
653fn encrypt_row(
654    table: &TableSchema,
655    _row_id: &str,
656    _row: &mut Row,
657    _encryption: &EncryptionConfig,
658) -> Result<(), String> {
659    Err(format!(
660        "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
661        table.name
662    ))
663}
664
665#[cfg(not(feature = "e2ee"))]
666fn decrypt_row(
667    table: &TableSchema,
668    _row: &mut Row,
669    _encryption: &EncryptionConfig,
670) -> Result<(), String> {
671    Err(format!(
672        "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
673        table.name
674    ))
675}
676
677#[cfg(feature = "e2ee")]
678fn column_value_to_plain(value: &ColumnValue) -> Result<ssp2::crypto::PlainValue, String> {
679    use ssp2::crypto::PlainValue;
680    Ok(match value {
681        ColumnValue::String(s) => PlainValue::String(s.clone()),
682        ColumnValue::Integer(i) => PlainValue::Integer(*i),
683        ColumnValue::Float(f) => PlainValue::Float(*f),
684        ColumnValue::Boolean(b) => PlainValue::Boolean(*b),
685        ColumnValue::Json(j) => PlainValue::Json(j.0.clone()),
686        ColumnValue::BlobRef(j) => PlainValue::BlobRef(j.0.clone()),
687        ColumnValue::Bytes(b) => PlainValue::Bytes(b.clone()),
688        ColumnValue::Crdt(_) => return Err("crdt columns cannot be encrypted (§5.11)".to_owned()),
689    })
690}
691
692#[cfg(feature = "e2ee")]
693fn plain_to_column_value(value: ssp2::crypto::PlainValue) -> ColumnValue {
694    use ssp2::crypto::PlainValue;
695    match value {
696        PlainValue::String(s) => ColumnValue::String(s),
697        PlainValue::Integer(i) => ColumnValue::Integer(i),
698        PlainValue::Float(f) => ColumnValue::Float(f),
699        PlainValue::Boolean(b) => ColumnValue::Boolean(b),
700        PlainValue::Json(s) => ColumnValue::Json(RawJson(s)),
701        PlainValue::BlobRef(s) => ColumnValue::BlobRef(RawJson(s)),
702        PlainValue::Bytes(b) => ColumnValue::Bytes(b),
703    }
704}
705
706/// Render a primary-key value as the wire `rowId` string.
707pub fn render_row_id(value: &Option<ColumnValue>) -> Result<String, String> {
708    match value {
709        Some(ColumnValue::String(s)) => Ok(s.clone()),
710        Some(ColumnValue::Integer(i)) => Ok(i.to_string()),
711        Some(ColumnValue::Float(f)) => Ok(f.to_string()),
712        Some(ColumnValue::Boolean(b)) => Ok(b.to_string()),
713        Some(ColumnValue::Json(raw)) => Ok(raw.0.clone()),
714        Some(ColumnValue::BlobRef(_)) => Err("blob_ref column cannot be a rowId".to_owned()),
715        Some(ColumnValue::Bytes(_)) => Err("bytes column cannot be a rowId".to_owned()),
716        Some(ColumnValue::Crdt(_)) => Err("crdt column cannot be a rowId".to_owned()),
717        None => Err("primary key value is missing".to_owned()),
718    }
719}
720
721/// Driver JSON value → wire `rowId` string (for optimistic upserts).
722pub fn render_row_id_json(value: Option<&Value>) -> Result<String, String> {
723    match value {
724        Some(Value::String(s)) => Ok(s.clone()),
725        Some(Value::Number(n)) => Ok(n.to_string()),
726        Some(Value::Bool(b)) => Ok(b.to_string()),
727        _ => Err("sync.invalid_request: primary key value is missing or not renderable".to_owned()),
728    }
729}
730
731/// Scope map as a driver JSON object (`variable → list of values`, §3.2).
732pub fn scope_map_to_json(scopes: &[(String, Vec<String>)]) -> Value {
733    let mut map = Map::new();
734    for (key, values) in scopes {
735        map.insert(
736            key.clone(),
737            Value::Array(values.iter().map(|v| Value::from(v.clone())).collect()),
738        );
739    }
740    Value::Object(map)
741}
742
743/// Driver JSON object → scope map, preserving key order.
744pub fn json_to_scope_map(value: &Value) -> Result<Vec<(String, Vec<String>)>, String> {
745    let object = value
746        .as_object()
747        .ok_or_else(|| "scope map must be an object".to_owned())?;
748    let mut out = Vec::with_capacity(object.len());
749    for (key, values) in object {
750        let list = values
751            .as_array()
752            .ok_or_else(|| format!("scope values for {key:?} must be a list (§0)"))?;
753        let mut strings = Vec::with_capacity(list.len());
754        for v in list {
755            strings.push(
756                v.as_str()
757                    .ok_or_else(|| format!("scope value for {key:?} is not a string"))?
758                    .to_owned(),
759            );
760        }
761        out.push((key.clone(), strings));
762    }
763    Ok(out)
764}
765
766#[cfg(test)]
767mod naming_tests {
768    use serde_json::{json, Map, Value};
769    use ssp2::segment::{Column, ColumnType, ColumnValue, Row};
770
771    use super::{
772        bytes_to_hex, hex_to_bytes, normalize_values_casing, snake_to_camel, EncryptionConfig,
773    };
774    use crate::schema::{EncryptedColumn, TableSchema};
775
776    #[test]
777    fn authored_integer_cells_respect_the_wire_safe_range() {
778        let column = Column {
779            name: "n".into(),
780            ty: ColumnType::Integer,
781            nullable: false,
782        };
783        for value in [
784            9007199254740992_i64,
785            9007199254740993,
786            i64::MAX,
787            -9007199254740992,
788            i64::MIN,
789        ] {
790            assert!(super::json_to_column_value(&column, Some(&json!(value))).is_err());
791        }
792        for value in [-9007199254740991_i64, 0, 9007199254740991] {
793            assert!(super::json_to_column_value(&column, Some(&json!(value))).is_ok());
794        }
795    }
796
797    #[test]
798    fn byte_envelopes_preserve_canonical_hex_for_every_byte() {
799        let bytes: Vec<u8> = (0..=255).collect();
800        let expected: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
801        assert_eq!(bytes_to_hex(&bytes), expected);
802        assert_eq!(hex_to_bytes(&expected).expect("decode"), bytes);
803        assert_eq!(bytes_to_hex(&[]), "");
804        assert_eq!(bytes_to_hex(&[0, 15, 16, 255]), "000f10ff");
805        let large = bytes.repeat(8192);
806        assert_eq!(bytes_to_hex(&large), expected.repeat(8192));
807        assert_eq!(
808            hex_to_bytes(&bytes_to_hex(&large)).expect("large decode"),
809            large
810        );
811    }
812
813    #[test]
814    fn byte_decoder_preserves_radix_parser_inputs_and_errors() {
815        for first in 0_u8..=127 {
816            for second in 0_u8..=127 {
817                let pair = [first, second];
818                let input = std::str::from_utf8(&pair).unwrap();
819                let expected = u8::from_str_radix(input, 16)
820                    .map(|byte| vec![byte])
821                    .map_err(|error| format!("bad hex: {error}"));
822                assert_eq!(hex_to_bytes(input), expected, "ASCII pair {pair:?}");
823            }
824        }
825        assert_eq!(hex_to_bytes("+fA0+100"), Ok(vec![15, 160, 1, 0]));
826        for input in [
827            "", "0", "abc", "é", "雪", "😀", "0é0", "é00", "00é", "00😀", "😀00", "\0\0",
828        ] {
829            let expected = if !input.len().is_multiple_of(2) {
830                Err("odd-length hex string".to_owned())
831            } else {
832                input
833                    .as_bytes()
834                    .chunks(2)
835                    .map(|pair| {
836                        let text =
837                            std::str::from_utf8(pair).map_err(|_| "non-ASCII hex".to_owned())?;
838                        u8::from_str_radix(text, 16).map_err(|error| format!("bad hex: {error}"))
839                    })
840                    .collect::<Result<Vec<_>, _>>()
841            };
842            assert_eq!(hex_to_bytes(input), expected, "input {input:?}");
843        }
844    }
845
846    #[test]
847    fn snake_to_camel_pinned_vectors() {
848        for (input, expected) in [
849            ("created_at", "createdAt"),
850            ("col_2", "col2"),
851            ("user_id", "userId"),
852            ("_internal", "_internal"),
853            ("__foo_bar", "__fooBar"),
854            ("row_", "row_"),
855            ("id_url", "idUrl"),
856            ("api_key", "apiKey"),
857            ("title", "title"),
858            ("alreadyCamel", "alreadyCamel"),
859            ("a__b", "aB"),
860            ("_lead_and_trail_", "_leadAndTrail_"),
861            ("count(*)", "count(*)"),
862        ] {
863            assert_eq!(snake_to_camel(input), expected, "input {input:?}");
864        }
865    }
866
867    #[test]
868    fn portable_key_selector_reads_a_non_encrypted_string_column() {
869        let columns = vec![
870            Column {
871                name: "id".to_owned(),
872                ty: ColumnType::String,
873                nullable: false,
874            },
875            Column {
876                name: "encryption_key_id".to_owned(),
877                ty: ColumnType::String,
878                nullable: false,
879            },
880            Column {
881                name: "note".to_owned(),
882                ty: ColumnType::String,
883                nullable: false,
884            },
885        ];
886        let table = TableSchema {
887            name: "patients".to_owned(),
888            columns: columns.clone(),
889            wire_columns: columns,
890            primary_key: "id".to_owned(),
891            pk_index: 0,
892            scope_variables: Vec::new(),
893            indexes: Vec::new(),
894            fts_indexes: Vec::new(),
895            encrypted_columns: vec![EncryptedColumn {
896                index: 2,
897                declared_type: "string".to_owned(),
898            }],
899        };
900        let mut config = EncryptionConfig::default();
901        config
902            .key_id_columns
903            .insert("patients".to_owned(), "encryption_key_id".to_owned());
904        let row: Row = vec![
905            Some(ColumnValue::String("patient-1".to_owned())),
906            Some(ColumnValue::String("practice-key-v1".to_owned())),
907            Some(ColumnValue::String("Identity".to_owned())),
908        ];
909        assert_eq!(
910            config.key_id_for(&table, &row).expect("selects key"),
911            "practice-key-v1"
912        );
913    }
914
915    #[test]
916    fn debug_render_redacts_key_material() {
917        let mut config = EncryptionConfig::default();
918        config
919            .keys
920            .insert("practice-key-v1".to_owned(), vec![0xAB; 32]);
921        config
922            .key_id_columns
923            .insert("patients".to_owned(), "encryption_key_id".to_owned());
924        let rendered = format!("{config:?}");
925        assert!(rendered.contains("practice-key-v1"), "{rendered}");
926        assert!(rendered.contains("<32 bytes>"), "{rendered}");
927        assert!(rendered.contains("encryption_key_id"), "{rendered}");
928        // Raw byte values never appear (0xAB renders as 171 under derive).
929        assert!(!rendered.contains("171"), "{rendered}");
930        assert!(!rendered.contains("0xAB"), "{rendered}");
931    }
932
933    fn table(names: &[&str]) -> TableSchema {
934        let columns: Vec<Column> = names
935            .iter()
936            .map(|n| Column {
937                name: (*n).to_owned(),
938                ty: ColumnType::String,
939                nullable: true,
940            })
941            .collect();
942        TableSchema {
943            name: "t".to_owned(),
944            columns: columns.clone(),
945            wire_columns: columns,
946            primary_key: "id".to_owned(),
947            pk_index: 0,
948            scope_variables: Vec::new(),
949            indexes: Vec::new(),
950            fts_indexes: Vec::new(),
951            encrypted_columns: Vec::new(),
952        }
953    }
954
955    fn map(entries: &[(&str, &str)]) -> Map<String, Value> {
956        entries
957            .iter()
958            .map(|(k, v)| ((*k).to_owned(), json!(v)))
959            .collect()
960    }
961
962    #[test]
963    fn camel_keys_normalize_to_sql_names() {
964        let t = table(&["id", "list_id", "updated_at_ms"]);
965        let out = normalize_values_casing(
966            &t,
967            map(&[("id", "x"), ("listId", "l"), ("updatedAtMs", "9")]),
968        )
969        .expect("normalizes");
970        assert_eq!(out.get("list_id"), Some(&json!("l")));
971        assert_eq!(out.get("updated_at_ms"), Some(&json!("9")));
972        assert!(!out.contains_key("listId"));
973    }
974
975    #[test]
976    fn snake_keys_pass_through() {
977        let t = table(&["id", "list_id"]);
978        let out = normalize_values_casing(&t, map(&[("id", "x"), ("list_id", "l")])).expect("ok");
979        assert_eq!(out.get("list_id"), Some(&json!("l")));
980    }
981
982    #[test]
983    fn both_casings_for_one_column_is_an_error() {
984        let t = table(&["id", "list_id"]);
985        let err = normalize_values_casing(&t, map(&[("list_id", "a"), ("listId", "b")]))
986            .expect_err("rejects");
987        assert!(err.contains("both snake_case and camelCase"), "{err}");
988    }
989
990    #[test]
991    fn an_alias_colliding_with_a_real_column_never_steals_it() {
992        // `col_2` camel-maps to `col2`, which IS a column: exact wins, no rename.
993        let t = table(&["id", "col_2", "col2"]);
994        let out = normalize_values_casing(&t, map(&[("id", "x"), ("col2", "v")])).expect("ok");
995        assert_eq!(out.get("col2"), Some(&json!("v")));
996        assert!(!out.contains_key("col_2"));
997    }
998
999    #[test]
1000    fn unknown_and_internal_columns_fail_loud() {
1001        let t = table(&["id", "title"]);
1002        let unknown = normalize_values_casing(&t, map(&[("id", "x"), ("typo", "v")]))
1003            .expect_err("unknown field");
1004        assert!(unknown.contains("unknown column"), "{unknown}");
1005        let internal = normalize_values_casing(&t, map(&[("id", "x"), ("_sync_version", "1")]))
1006            .expect_err("internal field");
1007        assert!(internal.contains("internal sync column"), "{internal}");
1008    }
1009}
1010
1011#[cfg(test)]
1012mod sparse_key_tests {
1013    use ssp2::segment::{Column, ColumnType, ColumnValue, Row};
1014
1015    use super::EncryptionConfig;
1016    use crate::schema::{EncryptedColumn, TableSchema};
1017
1018    #[cfg(feature = "e2ee")]
1019    use super::encode_sparse_row_json;
1020    #[cfg(feature = "e2ee")]
1021    use serde_json::{json, Map, Value};
1022
1023    fn keyed_table() -> TableSchema {
1024        let columns = vec![
1025            Column {
1026                name: "id".to_owned(),
1027                ty: ColumnType::String,
1028                nullable: false,
1029            },
1030            Column {
1031                name: "encryption_key_id".to_owned(),
1032                ty: ColumnType::String,
1033                nullable: true,
1034            },
1035            Column {
1036                name: "note".to_owned(),
1037                ty: ColumnType::String,
1038                nullable: true,
1039            },
1040        ];
1041        let wire_columns = vec![
1042            Column {
1043                name: "id".to_owned(),
1044                ty: ColumnType::String,
1045                nullable: false,
1046            },
1047            Column {
1048                name: "encryption_key_id".to_owned(),
1049                ty: ColumnType::String,
1050                nullable: true,
1051            },
1052            Column {
1053                name: "note".to_owned(),
1054                ty: ColumnType::Bytes,
1055                nullable: true,
1056            },
1057        ];
1058        TableSchema {
1059            name: "t".to_owned(),
1060            columns,
1061            wire_columns,
1062            primary_key: "id".to_owned(),
1063            pk_index: 0,
1064            scope_variables: Vec::new(),
1065            indexes: Vec::new(),
1066            fts_indexes: Vec::new(),
1067            encrypted_columns: vec![EncryptedColumn {
1068                index: 2,
1069                declared_type: "string".to_owned(),
1070            }],
1071        }
1072    }
1073
1074    fn keyed_config() -> EncryptionConfig {
1075        let mut config = EncryptionConfig::default();
1076        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
1077        config
1078            .key_id_columns
1079            .insert("t".to_owned(), "encryption_key_id".to_owned());
1080        config
1081    }
1082
1083    #[cfg(feature = "e2ee")]
1084    fn values(entries: &[(&str, Value)]) -> Map<String, Value> {
1085        entries
1086            .iter()
1087            .map(|(key, value)| ((*key).to_owned(), value.clone()))
1088            .collect()
1089    }
1090
1091    #[test]
1092    fn key_selection_failures_carry_the_encode_seam_code() {
1093        let table = keyed_table();
1094        let config = keyed_config();
1095        // Absent selector: absent is not NULL and never densifies into one.
1096        let row: Row = vec![
1097            Some(ColumnValue::String("r1".to_owned())),
1098            None,
1099            Some(ColumnValue::String("hi".to_owned())),
1100        ];
1101        let error = config.key_id_for(&table, &row).expect_err("rejects");
1102        assert!(error.starts_with("client.encrypt_failed"), "{error}");
1103        assert!(!error.starts_with("client.decrypt_failed"), "{error}");
1104    }
1105
1106    #[cfg(feature = "e2ee")]
1107    #[test]
1108    fn sparse_patch_without_an_encrypted_slot_needs_no_key() {
1109        let table = keyed_table();
1110        // No keys at all: resolution must be skipped, not failed.
1111        let config = EncryptionConfig::default();
1112        let payload =
1113            encode_sparse_row_json(&table, "r1", &values(&[("id", json!("r1"))]), &config, None)
1114                .expect("encodes without key resolution");
1115        assert!(!payload.is_empty());
1116    }
1117
1118    #[cfg(feature = "e2ee")]
1119    #[test]
1120    fn sparse_patch_falls_back_to_the_stored_key_id() {
1121        let table = keyed_table();
1122        let config = keyed_config();
1123        // The patch presents an encrypted column but not the selector.
1124        let sparse = values(&[("id", json!("r1")), ("note", json!("hi"))]);
1125        let error = encode_sparse_row_json(&table, "r1", &sparse, &config, None)
1126            .expect_err("absent selector with no fallback fails");
1127        assert!(error.contains("client.encrypt_failed"), "{error}");
1128        let fallback: Row = vec![
1129            Some(ColumnValue::String("r1".to_owned())),
1130            Some(ColumnValue::String("k1".to_owned())),
1131            None,
1132        ];
1133        encode_sparse_row_json(&table, "r1", &sparse, &config, Some(&fallback))
1134            .expect("stored key id encrypts the patch");
1135    }
1136
1137    #[cfg(feature = "e2ee")]
1138    #[test]
1139    fn sparse_patch_keeps_a_present_null_selector() {
1140        let table = keyed_table();
1141        let config = keyed_config();
1142        // The patch presents the selector explicitly as NULL alongside an
1143        // encrypted column. Absent is not NULL: the stored row must not
1144        // rescue a present NULL, and a NULL key id is unusable.
1145        let sparse = values(&[
1146            ("id", json!("r1")),
1147            ("encryption_key_id", json!(null)),
1148            ("note", json!("hi")),
1149        ]);
1150        let fallback: Row = vec![
1151            Some(ColumnValue::String("r1".to_owned())),
1152            Some(ColumnValue::String("k1".to_owned())),
1153            None,
1154        ];
1155        let error = encode_sparse_row_json(&table, "r1", &sparse, &config, Some(&fallback))
1156            .expect_err("a present NULL selector must not read the stored fallback");
1157        assert!(error.contains("client.encrypt_failed"), "{error}");
1158    }
1159}