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