Skip to main content

syncular_client/
values.rs

1//! Value conversions at the client's edges: driver JSON (`{"$bytes": hex}`
2//! convention) ↔ the §2.4 row-codec values ↔ SQLite storage, plus the §11.2
3//! canonical scope JSON (contractual across implementations).
4
5use std::collections::HashMap;
6
7use serde_json::{Map, Value};
8use ssp2::primitives::{RawJson, Reader, Writer};
9use ssp2::segment::{decode_row, encode_row, Column, ColumnType, ColumnValue, Row};
10use ssp2::util::utf16_lt;
11
12use crate::schema::TableSchema;
13
14/// §5.11 client-side encryption keys (`keyId → 32-byte key`) plus optional
15/// key selection. Empty ⇒ E2EE off. `key_id_for` defaults to per-table
16/// (`keyId = table`). Always present on the client; the crypto is compiled
17/// only under the `e2ee` feature.
18#[derive(Debug, Clone, Default)]
19pub struct EncryptionConfig {
20    pub keys: HashMap<String, Vec<u8>>,
21}
22
23impl EncryptionConfig {
24    pub fn is_empty(&self) -> bool {
25        self.keys.is_empty()
26    }
27
28    /// §5.11 default key selection: per-table (`keyId = table`).
29    pub fn key_id_for(&self, table: &str, _row_id: &str) -> String {
30        table.to_owned()
31    }
32}
33
34pub fn bytes_to_hex(bytes: &[u8]) -> String {
35    let mut out = String::with_capacity(bytes.len() * 2);
36    for b in bytes {
37        out.push_str(&format!("{b:02x}"));
38    }
39    out
40}
41
42pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
43    if !hex.len().is_multiple_of(2) {
44        return Err("odd-length hex string".to_owned());
45    }
46    let mut out = Vec::with_capacity(hex.len() / 2);
47    let bytes = hex.as_bytes();
48    for pair in bytes.chunks(2) {
49        let s = std::str::from_utf8(pair).map_err(|_| "non-ASCII hex".to_owned())?;
50        out.push(u8::from_str_radix(s, 16).map_err(|e| format!("bad hex: {e}"))?);
51    }
52    Ok(out)
53}
54
55/// Driver JSON value → row-codec value for one column. `null`/absent maps to
56/// NULL; bytes travel as `{"$bytes": "<hex>"}`.
57pub fn json_to_column_value(
58    column: &Column,
59    value: Option<&Value>,
60) -> Result<Option<ColumnValue>, String> {
61    let value = match value {
62        None | Some(Value::Null) => return Ok(None),
63        Some(v) => v,
64    };
65    let fail = |expected: &str| {
66        Err(format!(
67            "column {:?}: expected {expected}, got {value}",
68            column.name
69        ))
70    };
71    match column.ty {
72        ColumnType::String => match value.as_str() {
73            Some(s) => Ok(Some(ColumnValue::String(s.to_owned()))),
74            None => fail("a string"),
75        },
76        ColumnType::Integer => match value.as_i64() {
77            Some(i) => Ok(Some(ColumnValue::Integer(i))),
78            None => fail("an integer"),
79        },
80        ColumnType::Float => match value.as_f64() {
81            Some(f) => Ok(Some(ColumnValue::Float(f))),
82            None => fail("a number"),
83        },
84        ColumnType::Boolean => match value.as_bool() {
85            Some(b) => Ok(Some(ColumnValue::Boolean(b))),
86            None => fail("a boolean"),
87        },
88        // `json` columns stay raw strings at the driver boundary (§2.4).
89        ColumnType::Json => match value.as_str() {
90            Some(s) => Ok(Some(ColumnValue::Json(RawJson(s.to_owned())))),
91            None => fail("a raw JSON string"),
92        },
93        // `blob_ref` (tag 7) also stays a raw string at the boundary (§5.9.1).
94        ColumnType::BlobRef => match value.as_str() {
95            Some(s) => Ok(Some(ColumnValue::BlobRef(RawJson(s.to_owned())))),
96            None => fail("a raw BlobRef JSON string"),
97        },
98        ColumnType::Bytes => match value.get("$bytes").and_then(Value::as_str) {
99            Some(hex) => Ok(Some(ColumnValue::Bytes(hex_to_bytes(hex)?))),
100            None => fail("a {\"$bytes\": hex} object"),
101        },
102        // §5.10: crdt bytes cross the boundary as {"$bytes": hex}, like bytes.
103        ColumnType::Crdt => match value.get("$bytes").and_then(Value::as_str) {
104            Some(hex) => Ok(Some(ColumnValue::Crdt(hex_to_bytes(hex)?))),
105            None => fail("a {\"$bytes\": hex} object"),
106        },
107    }
108}
109
110/// Row-codec value → driver JSON value.
111pub fn column_value_to_json(value: &Option<ColumnValue>) -> Value {
112    match value {
113        None => Value::Null,
114        Some(ColumnValue::String(s)) => Value::from(s.clone()),
115        Some(ColumnValue::Integer(i)) => Value::from(*i),
116        Some(ColumnValue::Float(f)) => {
117            serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number)
118        }
119        Some(ColumnValue::Boolean(b)) => Value::from(*b),
120        Some(ColumnValue::Json(raw)) => Value::from(raw.0.clone()),
121        Some(ColumnValue::BlobRef(raw)) => Value::from(raw.0.clone()),
122        Some(ColumnValue::Bytes(bytes)) => {
123            let mut map = Map::new();
124            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
125            Value::Object(map)
126        }
127        Some(ColumnValue::Crdt(bytes)) => {
128            let mut map = Map::new();
129            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
130            Value::Object(map)
131        }
132    }
133}
134
135/// Encode one full row (driver JSON values keyed by column name) with the
136/// generated row codec (§2.4, §6.1). §5.11: encrypted columns are encrypted
137/// here — the encode-at-send seam — before the codec serializes them as
138/// ciphertext-envelope `bytes` using `wire_columns`.
139pub fn encode_row_json(
140    table: &TableSchema,
141    row_id: &str,
142    values: &Map<String, Value>,
143    encryption: &EncryptionConfig,
144) -> Result<Vec<u8>, String> {
145    // Build the row from the LOCAL (declared-type) columns.
146    let mut row: Row = Vec::with_capacity(table.columns.len());
147    for column in &table.columns {
148        row.push(json_to_column_value(column, values.get(&column.name))?);
149    }
150    if table.has_encrypted_columns() {
151        encrypt_row(table, row_id, &mut row, encryption)?;
152    }
153    // Serialize with the WIRE columns (encrypted columns are `bytes`).
154    let mut w = Writer::new();
155    encode_row(&mut w, &table.wire_columns, &row);
156    Ok(w.into_bytes())
157}
158
159/// Decode one row-codec payload; trailing bytes are a decode error. §5.11:
160/// encrypted columns are decrypted here — the apply seam — back to their
161/// declared-type plaintext value for the local mirror.
162pub fn decode_row_bytes(
163    table: &TableSchema,
164    payload: &[u8],
165    encryption: &EncryptionConfig,
166) -> Result<Row, String> {
167    let mut r = Reader::new(payload);
168    // Decode with the WIRE columns (encrypted columns arrive as `bytes`).
169    let mut row = decode_row(&mut r, &table.wire_columns).map_err(|e| e.to_string())?;
170    if !r.is_empty() {
171        return Err("row payload has trailing bytes".to_owned());
172    }
173    if table.has_encrypted_columns() {
174        decrypt_row(table, &mut row, encryption)?;
175    }
176    Ok(row)
177}
178
179/// §5.11: decrypt the encrypted columns of an already-decoded segment row
180/// (rows segments decode via their own column table, so decryption is a
181/// post-decode pass over the positional values).
182pub fn decrypt_segment_row(
183    table: &TableSchema,
184    row: &mut Row,
185    encryption: &EncryptionConfig,
186) -> Result<(), String> {
187    decrypt_row(table, row, encryption)
188}
189
190// -- §5.11 encrypt/decrypt seam (feature-gated) ------------------------------
191
192#[cfg(feature = "e2ee")]
193fn encrypt_row(
194    table: &TableSchema,
195    row_id: &str,
196    row: &mut Row,
197    encryption: &EncryptionConfig,
198) -> Result<(), String> {
199    use rand_core::RngCore;
200    use ssp2::crypto::{encrypt_value, NONCE_LENGTH};
201    for enc in &table.encrypted_columns {
202        let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
203            continue; // NULL stays NULL (§5.11)
204        };
205        let plain = column_value_to_plain(value)?;
206        let key_id = encryption.key_id_for(&table.name, row_id);
207        let key = encryption
208            .keys
209            .get(&key_id)
210            .ok_or_else(|| format!("client.decrypt_failed: no key for keyId {key_id:?}"))?;
211        let mut nonce = [0u8; NONCE_LENGTH];
212        rand_core::OsRng.fill_bytes(&mut nonce);
213        let envelope = encrypt_value(&plain, &key_id, key, nonce)?;
214        row[enc.index] = Some(ColumnValue::Bytes(envelope));
215    }
216    Ok(())
217}
218
219#[cfg(feature = "e2ee")]
220fn decrypt_row(
221    table: &TableSchema,
222    row: &mut Row,
223    encryption: &EncryptionConfig,
224) -> Result<(), String> {
225    use ssp2::crypto::{decrypt_value, DeclaredType};
226    for enc in &table.encrypted_columns {
227        let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
228            continue;
229        };
230        let ColumnValue::Bytes(envelope) = value else {
231            return Err(format!(
232                "client.decrypt_failed: encrypted column at index {} is not bytes",
233                enc.index
234            ));
235        };
236        let declared = DeclaredType::from_name(&enc.declared_type)
237            .ok_or_else(|| format!("unknown declaredType {:?}", enc.declared_type))?;
238        let keys = &encryption.keys;
239        let plain = decrypt_value(declared, envelope, |id| keys.get(id).cloned())
240            .map_err(|e| e.to_string())?;
241        row[enc.index] = Some(plain_to_column_value(plain));
242    }
243    Ok(())
244}
245
246/// Without the `e2ee` feature, a schema with encrypted columns is a
247/// misconfiguration — fail loud rather than ship plaintext.
248#[cfg(not(feature = "e2ee"))]
249fn encrypt_row(
250    table: &TableSchema,
251    _row_id: &str,
252    _row: &mut Row,
253    _encryption: &EncryptionConfig,
254) -> Result<(), String> {
255    Err(format!(
256        "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
257        table.name
258    ))
259}
260
261#[cfg(not(feature = "e2ee"))]
262fn decrypt_row(
263    table: &TableSchema,
264    _row: &mut Row,
265    _encryption: &EncryptionConfig,
266) -> Result<(), String> {
267    Err(format!(
268        "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
269        table.name
270    ))
271}
272
273#[cfg(feature = "e2ee")]
274fn column_value_to_plain(value: &ColumnValue) -> Result<ssp2::crypto::PlainValue, String> {
275    use ssp2::crypto::PlainValue;
276    Ok(match value {
277        ColumnValue::String(s) => PlainValue::String(s.clone()),
278        ColumnValue::Integer(i) => PlainValue::Integer(*i),
279        ColumnValue::Float(f) => PlainValue::Float(*f),
280        ColumnValue::Boolean(b) => PlainValue::Boolean(*b),
281        ColumnValue::Json(j) => PlainValue::Json(j.0.clone()),
282        ColumnValue::BlobRef(j) => PlainValue::BlobRef(j.0.clone()),
283        ColumnValue::Bytes(b) => PlainValue::Bytes(b.clone()),
284        ColumnValue::Crdt(_) => return Err("crdt columns cannot be encrypted (§5.11)".to_owned()),
285    })
286}
287
288#[cfg(feature = "e2ee")]
289fn plain_to_column_value(value: ssp2::crypto::PlainValue) -> ColumnValue {
290    use ssp2::crypto::PlainValue;
291    match value {
292        PlainValue::String(s) => ColumnValue::String(s),
293        PlainValue::Integer(i) => ColumnValue::Integer(i),
294        PlainValue::Float(f) => ColumnValue::Float(f),
295        PlainValue::Boolean(b) => ColumnValue::Boolean(b),
296        PlainValue::Json(s) => ColumnValue::Json(RawJson(s)),
297        PlainValue::BlobRef(s) => ColumnValue::BlobRef(RawJson(s)),
298        PlainValue::Bytes(b) => ColumnValue::Bytes(b),
299    }
300}
301
302/// Render a primary-key value as the wire `rowId` string.
303pub fn render_row_id(value: &Option<ColumnValue>) -> Result<String, String> {
304    match value {
305        Some(ColumnValue::String(s)) => Ok(s.clone()),
306        Some(ColumnValue::Integer(i)) => Ok(i.to_string()),
307        Some(ColumnValue::Float(f)) => Ok(f.to_string()),
308        Some(ColumnValue::Boolean(b)) => Ok(b.to_string()),
309        Some(ColumnValue::Json(raw)) => Ok(raw.0.clone()),
310        Some(ColumnValue::BlobRef(_)) => Err("blob_ref column cannot be a rowId".to_owned()),
311        Some(ColumnValue::Bytes(_)) => Err("bytes column cannot be a rowId".to_owned()),
312        Some(ColumnValue::Crdt(_)) => Err("crdt column cannot be a rowId".to_owned()),
313        None => Err("primary key value is missing".to_owned()),
314    }
315}
316
317/// Driver JSON value → wire `rowId` string (for optimistic upserts).
318pub fn render_row_id_json(value: Option<&Value>) -> Result<String, String> {
319    match value {
320        Some(Value::String(s)) => Ok(s.clone()),
321        Some(Value::Number(n)) => Ok(n.to_string()),
322        Some(Value::Bool(b)) => Ok(b.to_string()),
323        _ => Err("primary key value is missing or not renderable".to_owned()),
324    }
325}
326
327fn sort_utf16(values: &mut [String]) {
328    values.sort_by(|a, b| {
329        if utf16_lt(a, b) {
330            std::cmp::Ordering::Less
331        } else if utf16_lt(b, a) {
332            std::cmp::Ordering::Greater
333        } else {
334            std::cmp::Ordering::Equal
335        }
336    });
337}
338
339/// Sort scope-map keys into ascending code-unit order (the canonical `map`
340/// encoding of the Conventions section).
341pub fn sort_scope_map(map: &mut [(String, Vec<String>)]) {
342    map.sort_by(|a, b| {
343        if utf16_lt(&a.0, &b.0) {
344            std::cmp::Ordering::Less
345        } else if utf16_lt(&b.0, &a.0) {
346            std::cmp::Ordering::Greater
347        } else {
348            std::cmp::Ordering::Equal
349        }
350    });
351}
352
353/// §11.2 canonical JSON of a scope map: keys sorted by code-unit, value
354/// lists sorted and deduplicated, no insignificant whitespace.
355pub fn canonical_scope_json(scopes: &[(String, Vec<String>)]) -> String {
356    let mut entries: Vec<(String, Vec<String>)> = scopes.to_vec();
357    sort_scope_map(&mut entries);
358    let mut out = String::from("{");
359    for (i, (key, values)) in entries.iter().enumerate() {
360        if i > 0 {
361            out.push(',');
362        }
363        let mut sorted = values.clone();
364        sort_utf16(&mut sorted);
365        sorted.dedup();
366        out.push_str(&serde_json::to_string(key).expect("string serializes"));
367        out.push_str(":[");
368        for (j, value) in sorted.iter().enumerate() {
369            if j > 0 {
370                out.push(',');
371            }
372            out.push_str(&serde_json::to_string(value).expect("string serializes"));
373        }
374        out.push(']');
375    }
376    out.push('}');
377    out
378}
379
380/// Scope map as a driver JSON object (`variable → list of values`, §3.2).
381pub fn scope_map_to_json(scopes: &[(String, Vec<String>)]) -> Value {
382    let mut map = Map::new();
383    for (key, values) in scopes {
384        map.insert(
385            key.clone(),
386            Value::Array(values.iter().map(|v| Value::from(v.clone())).collect()),
387        );
388    }
389    Value::Object(map)
390}
391
392/// Driver JSON object → scope map, preserving key order.
393pub fn json_to_scope_map(value: &Value) -> Result<Vec<(String, Vec<String>)>, String> {
394    let object = value
395        .as_object()
396        .ok_or_else(|| "scope map must be an object".to_owned())?;
397    let mut out = Vec::with_capacity(object.len());
398    for (key, values) in object {
399        let list = values
400            .as_array()
401            .ok_or_else(|| format!("scope values for {key:?} must be a list (§0)"))?;
402        let mut strings = Vec::with_capacity(list.len());
403        for v in list {
404            strings.push(
405                v.as_str()
406                    .ok_or_else(|| format!("scope value for {key:?} is not a string"))?
407                    .to_owned(),
408            );
409        }
410        out.push((key.clone(), strings));
411    }
412    Ok(out)
413}