Skip to main content

hayson_binding_shared/
options.rs

1use std::collections::HashMap;
2
3use std::time::Duration;
4
5use hayson_engine::catalog::CatalogCompaction;
6use hayson_engine::crypto::PasswordHashParams;
7use hayson_engine::crypto::{CipherOptions, MissingKeyPolicy};
8use hayson_engine::database::{
9    BackupOptions, ConvertOptions, FollowOption, LayoutKind, LayoutMigrationOptions, LayoutOptions, LockMode,
10    OpenOptions, PackInclude, PackLimits, PackOptions, RestoreOptions, UnpackOptions, VerifyOptions,
11};
12use hayson_engine::lock::{GraceServed, LockOptions};
13use serde_json::Value;
14
15/// Rejection of an `open()` options document, before the engine is ever touched.
16#[derive(Debug, PartialEq, Eq)]
17pub enum OptionsError {
18    /// A malformed option: wrong JSON type, unknown enum member, out-of-range number.
19    Shape(String),
20    /// Unusable key material — reported to JS as a cipher error, not a schema one.
21    Key(String),
22}
23
24fn shape<T>(message: impl Into<String>) -> Result<T, OptionsError> {
25    Err(OptionsError::Shape(message.into()))
26}
27
28fn as_object<'a>(value: &'a Value, what: &str) -> Result<&'a serde_json::Map<String, Value>, OptionsError> {
29    match value.as_object() {
30        Some(object) => Ok(object),
31        None => shape(format!("\"{what}\" must be a JSON object")),
32    }
33}
34
35fn optional_bool(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<bool>, OptionsError> {
36    match object.get(key) {
37        None | Some(Value::Null) => Ok(None),
38        Some(Value::Bool(value)) => Ok(Some(*value)),
39        Some(_) => shape(format!("\"{key}\" must be a boolean")),
40    }
41}
42
43fn optional_u64(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<u64>, OptionsError> {
44    match object.get(key) {
45        None | Some(Value::Null) => Ok(None),
46        Some(Value::Number(number)) => match number.as_u64() {
47            Some(value) => Ok(Some(value)),
48            None => shape(format!("\"{key}\" must be a non-negative whole number")),
49        },
50        Some(_) => shape(format!("\"{key}\" must be a number")),
51    }
52}
53
54fn optional_i64(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<i64>, OptionsError> {
55    match object.get(key) {
56        None | Some(Value::Null) => Ok(None),
57        Some(Value::Number(number)) => match number.as_i64() {
58            Some(value) => Ok(Some(value)),
59            None => shape(format!("\"{key}\" must be a whole number")),
60        },
61        Some(_) => shape(format!("\"{key}\" must be a number")),
62    }
63}
64
65fn optional_string(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<String>, OptionsError> {
66    match object.get(key) {
67        None | Some(Value::Null) => Ok(None),
68        Some(Value::String(value)) => Ok(Some(value.clone())),
69        Some(_) => shape(format!("\"{key}\" must be a string")),
70    }
71}
72
73/// `lock` in the shapes the TS `OpenOptions` takes (`boolean | LockOptions`): an object of lease
74/// tuning, or `false` to open without taking the writer lock at all — mutual exclusion between
75/// writers is then not enforced, which is the whole point and the whole danger of the option.
76/// `true` is TS's "lock the usual way" and resolves to the engine's defaults, not to skipping.
77fn parse_lock(value: &Value) -> Result<LockMode, OptionsError> {
78    match value {
79        Value::Bool(false) => Ok(LockMode::Skip),
80        Value::Bool(true) => Ok(LockMode::Enabled(LockOptions::default())),
81        Value::Object(object) => Ok(LockMode::Enabled(LockOptions {
82            stale_after_ms: optional_u64(object, "staleAfterMs")?,
83            heartbeat_ms: optional_u64(object, "heartbeatMs")?,
84            timeout_ms: optional_u64(object, "timeoutMs")?,
85            retry_interval_ms: optional_u64(object, "retryIntervalMs")?,
86            grace_served: parse_grace_served(object)?,
87        })),
88        _ => shape("\"lock\" must be a boolean or an object of lock options"),
89    }
90}
91
92/// `graceServed` — the lease a host has already challenged and waited the hand-over grace out on,
93/// so that the open it precedes reclaims that lease without the engine sleeping inside a call the
94/// host is waiting on. Not part of the public TS `LockOptions`: the binding layer synthesises it
95/// between two attempts of its own, off the `holder` its previous refusal named.
96///
97/// A token naming a lease that has since moved is not an error — it simply buys nothing, and the
98/// ordinary hand-over runs instead. Only its shape is judged here.
99fn parse_grace_served(object: &serde_json::Map<String, Value>) -> Result<Option<GraceServed>, OptionsError> {
100    let served = match object.get("graceServed") {
101        None | Some(Value::Null) => return Ok(None),
102        Some(value) => as_object(value, "graceServed")?,
103    };
104    let Some(owner) = optional_string(served, "owner")? else {
105        return shape("\"graceServed\" must name the \"owner\" whose grace it waited out");
106    };
107    let Some(heartbeat_at) = optional_i64(served, "heartbeatAt")? else {
108        return shape("\"graceServed\" must name the \"heartbeatAt\" whose grace it waited out");
109    };
110    Ok(GraceServed::parse(&owner, heartbeat_at))
111}
112
113/// `follow` in the three shapes the TS `OpenOptions` takes: `false` to never catch up unprompted,
114/// `true` for the engine's default interval, or `{ intervalMs }` for a custom one. Absent is not
115/// handled here — it means "resolve by `readonly`" and never reaches this function.
116fn parse_follow(value: &Value) -> Result<FollowOption, OptionsError> {
117    match value {
118        Value::Bool(false) => Ok(FollowOption::Off),
119        Value::Bool(true) => Ok(FollowOption::Every(Duration::ZERO)),
120        Value::Object(object) => match optional_u64(object, "intervalMs")? {
121            Some(interval_ms) => Ok(FollowOption::Every(Duration::from_millis(interval_ms))),
122            None => Ok(FollowOption::Every(Duration::ZERO)),
123        },
124        _ => shape("\"follow\" must be a boolean or an object with \"intervalMs\""),
125    }
126}
127
128/// `compaction` in the two shapes the TS `OpenOptions` takes: `false` to never rewrite a log
129/// unprompted, or `{ deadBytesRatio, minDeadBytes }` to set both thresholds. Absent is not handled
130/// here — it means "leave it to the catalog" and never reaches this function.
131fn parse_compaction(value: &Value) -> Result<CatalogCompaction, OptionsError> {
132    match value {
133        Value::Bool(false) => Ok(CatalogCompaction::Disabled),
134        Value::Object(object) => {
135            let ratio = match object.get("deadBytesRatio") {
136                None | Some(Value::Null) => None,
137                Some(Value::Number(number)) => number.as_f64(),
138                Some(_) => return shape("\"deadBytesRatio\" must be a number"),
139            };
140            let Some(dead_bytes_ratio) = ratio.filter(|ratio| *ratio > 0.0 && *ratio <= 1.0) else {
141                return shape("\"deadBytesRatio\" must be a number greater than 0 and at most 1");
142            };
143            let min_dead_bytes = optional_u64(object, "minDeadBytes")?.unwrap_or(0);
144            Ok(CatalogCompaction::Enabled {
145                dead_bytes_ratio,
146                min_dead_bytes,
147            })
148        }
149        _ => shape("\"compaction\" must be false or an object of compaction thresholds"),
150    }
151}
152
153fn parse_layout_kind(raw: &str) -> Result<LayoutKind, OptionsError> {
154    match raw {
155        "flat" => Ok(LayoutKind::Flat),
156        "nested" => Ok(LayoutKind::Nested),
157        other => shape(format!("\"layout\" must be \"flat\" or \"nested\" — got \"{other}\"")),
158    }
159}
160
161/// The `layout`/`metadataFile`/`manifest` triple, passed on unresolved: whether the two file names
162/// agree, and whether the requested arrangement matches the one on disk, is the engine's call, so
163/// both refusals read the same however a caller reaches them.
164fn parse_layout(object: &serde_json::Map<String, Value>) -> Result<LayoutOptions, OptionsError> {
165    Ok(LayoutOptions {
166        kind: match optional_string(object, "layout")? {
167            Some(raw) => Some(parse_layout_kind(&raw)?),
168            None => None,
169        },
170        metadata_file: optional_string(object, "metadataFile")?,
171        manifest: optional_string(object, "manifest")?,
172    })
173}
174
175fn parse_missing_key_policy(raw: &str) -> Result<MissingKeyPolicy, OptionsError> {
176    match raw {
177        "throw" => Ok(MissingKeyPolicy::Throw),
178        "omit" => Ok(MissingKeyPolicy::Omit),
179        "raw" => Ok(MissingKeyPolicy::Raw),
180        other => shape(format!(
181            "\"onMissingKey\" must be one of \"throw\", \"omit\", \"raw\" — got \"{other}\""
182        )),
183    }
184}
185
186/// Decodes one key of the `cypher.keys` map. `encoding` is the document-wide `keyEncoding`.
187fn decode_key(id: &str, raw: &str, encoding: KeyEncoding) -> Result<Vec<u8>, OptionsError> {
188    let decoded = match encoding {
189        KeyEncoding::Base64 => decode_base64(raw),
190        KeyEncoding::Hex => decode_hex(raw),
191    };
192    decoded.ok_or_else(|| OptionsError::Key(format!("key \"{id}\" is not valid {}", encoding.as_str())))
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum KeyEncoding {
197    Base64,
198    Hex,
199}
200
201impl KeyEncoding {
202    fn as_str(self) -> &'static str {
203        match self {
204            KeyEncoding::Base64 => "base64",
205            KeyEncoding::Hex => "hex",
206        }
207    }
208}
209
210fn base64_symbol(byte: u8) -> Option<u8> {
211    match byte {
212        b'A'..=b'Z' => Some(byte - b'A'),
213        b'a'..=b'z' => Some(byte - b'a' + 26),
214        b'0'..=b'9' => Some(byte - b'0' + 52),
215        // NOTE: accepts the URL-safe alphabet too — Node emits it for `base64url` encodings.
216        b'+' | b'-' => Some(62),
217        b'/' | b'_' => Some(63),
218        _ => None,
219    }
220}
221
222/// Standard and URL-safe base64, padding optional. `None` on any symbol outside the alphabet or a
223/// length that cannot be a whole number of bytes.
224pub fn decode_base64(raw: &str) -> Option<Vec<u8>> {
225    let symbols: &[u8] = raw.trim_end_matches('=').as_bytes();
226    if symbols.len() % 4 == 1 {
227        return None;
228    }
229    let mut out = Vec::with_capacity(symbols.len() * 3 / 4);
230    let mut accumulator: u32 = 0;
231    let mut bits = 0u32;
232    for byte in symbols {
233        accumulator = (accumulator << 6) | u32::from(base64_symbol(*byte)?);
234        bits += 6;
235        if bits >= 8 {
236            bits -= 8;
237            out.push((accumulator >> bits) as u8);
238        }
239    }
240    // Leftover bits of a truncated group must be zero, or the input was not base64 of these bytes.
241    if accumulator & ((1 << bits) - 1) != 0 {
242        return None;
243    }
244    Some(out)
245}
246
247/// Lowercase or uppercase hex, no separators. `None` on an odd length or a non-hex digit.
248pub fn decode_hex(raw: &str) -> Option<Vec<u8>> {
249    let digits = raw.as_bytes();
250    if !digits.len().is_multiple_of(2) {
251        return None;
252    }
253    let mut out = Vec::with_capacity(digits.len() / 2);
254    for pair in digits.chunks_exact(2) {
255        let high = (pair[0] as char).to_digit(16)?;
256        let low = (pair[1] as char).to_digit(16)?;
257        out.push((high * 16 + low) as u8);
258    }
259    Some(out)
260}
261
262/// The `cypher` sub-document of the `open()` options document, on its own — `{ keys, keyId,
263/// keyEncoding, onMissingKey }`. Public because a host that reads key material from a file or an
264/// environment variable (`crate::keys`) has such a document and no options document around it.
265pub fn parse_cypher(value: &Value) -> Result<CipherOptions, OptionsError> {
266    parse_key_document(value, "cypher")
267}
268
269/// The keys document `encryption` takes: the same shape as `cypher`.
270pub fn parse_encryption(value: &Value) -> Result<CipherOptions, OptionsError> {
271    parse_key_document(value, "encryption")
272}
273
274fn parse_key_document(value: &Value, label: &str) -> Result<CipherOptions, OptionsError> {
275    let object = as_object(value, label)?;
276    let encoding = match optional_string(object, "keyEncoding")?.as_deref() {
277        None | Some("base64") => KeyEncoding::Base64,
278        Some("hex") => KeyEncoding::Hex,
279        Some(other) => {
280            return shape(format!(
281                "\"keyEncoding\" must be \"base64\" or \"hex\" — got \"{other}\""
282            ))
283        }
284    };
285
286    let raw_keys = match object.get("keys") {
287        Some(keys) => as_object(keys, &format!("{label}.keys"))?,
288        None => return shape(format!("\"{label}\" needs a \"keys\" object")),
289    };
290
291    let mut keys = HashMap::with_capacity(raw_keys.len());
292    for (id, material) in raw_keys {
293        let Some(material) = material.as_str() else {
294            return shape(format!("key \"{id}\" must be a string"));
295        };
296        keys.insert(id.clone(), decode_key(id, material, encoding)?);
297    }
298
299    let on_missing_key = match optional_string(object, "onMissingKey")? {
300        Some(raw) => Some(parse_missing_key_policy(&raw)?),
301        None => None,
302    };
303
304    Ok(CipherOptions {
305        keys,
306        key_id: optional_string(object, "keyId")?,
307        on_missing_key,
308    })
309}
310
311/// Builds `OpenOptions` from the JSON document `open()` takes. Every member is optional; an absent
312/// one keeps the engine's own default, so `{}` and `null` both mean "open it the usual way".
313pub fn parse_open_options(value: &Value) -> Result<OpenOptions, OptionsError> {
314    let mut options = OpenOptions::default();
315    if value.is_null() {
316        return Ok(options);
317    }
318    let object = as_object(value, "options")?;
319
320    if let Some(readonly) = optional_bool(object, "readonly")? {
321        options.readonly = readonly;
322    }
323    if let Some(snapshot_every) = optional_u64(object, "snapshotEvery")? {
324        options.snapshot_every = snapshot_every;
325    }
326    if let Some(compaction) = object.get("compaction").filter(|value| !value.is_null()) {
327        options.compaction = Some(parse_compaction(compaction)?);
328    }
329    if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
330        options.lock = parse_lock(lock)?;
331    }
332    if let Some(cypher) = object.get("cypher").filter(|value| !value.is_null()) {
333        options.cypher = Some(parse_cypher(cypher)?);
334    }
335    if let Some(encryption) = object.get("encryption").filter(|value| !value.is_null()) {
336        options.encryption = Some(parse_encryption(encryption)?);
337    }
338    if let Some(follow) = object.get("follow").filter(|value| !value.is_null()) {
339        options.follow = parse_follow(follow)?;
340    }
341    if let Some(max) = optional_u64(object, "maxImageBytes")? {
342        options.max_image_bytes = Some(max);
343    }
344    if let Some(cost) = object.get("passwordHash").filter(|value| !value.is_null()) {
345        options.password_hash = parse_password_hash(cost)?;
346    }
347    options.layout = parse_layout(object)?;
348
349    Ok(options)
350}
351
352/// Builds [`LayoutMigrationOptions`] from the JSON document `migrateLayout()` takes — the TS
353/// `LayoutMigrationOptions`, member for member, plus the `lock` of `open()` so a host that has to
354/// wait differently for the writer lock can say so. Every member is optional; `{}` and `null` both
355/// mean "move it into the nested arrangement, keeping the document's name".
356///
357/// Neither pair of document names is resolved here, for the same reason `parse_layout` resolves
358/// nothing: whether the two names of a pair agree is the engine's refusal to make, so it reads the
359/// same however a caller reaches it.
360pub fn parse_migrate_layout_options(value: &Value) -> Result<LayoutMigrationOptions, OptionsError> {
361    let mut options = LayoutMigrationOptions::default();
362    if value.is_null() {
363        return Ok(options);
364    }
365    let object = as_object(value, "options")?;
366
367    options.to = match optional_string(object, "to")? {
368        Some(raw) => Some(parse_migration_target(&raw)?),
369        None => None,
370    };
371    options.from_metadata_file = optional_string(object, "fromMetadataFile")?;
372    options.from_manifest = optional_string(object, "fromManifest")?;
373    options.metadata_file = optional_string(object, "metadataFile")?;
374    options.manifest_file = optional_string(object, "manifestFile")?;
375    options.dry_run = optional_bool(object, "dryRun")?.unwrap_or(false);
376    if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
377        options.lock = parse_lock(lock)?;
378    }
379
380    Ok(options)
381}
382
383/// Builds [`VerifyOptions`] from the JSON document `verify()` takes:
384/// `{ relations?: boolean, images?: boolean, maxImageBytes?: number }`.
385/// `null` and `{}` mean the defaults.
386pub fn parse_verify_options(value: &Value) -> Result<VerifyOptions, OptionsError> {
387    let mut options = VerifyOptions::default();
388    if value.is_null() {
389        return Ok(options);
390    }
391    let object = as_object(value, "options")?;
392    if let Some(relations) = optional_bool(object, "relations")? {
393        options.relations = relations;
394    }
395    if let Some(images) = optional_bool(object, "images")? {
396        options.images = images;
397    }
398    options.max_image_bytes = match object.get("maxImageBytes") {
399        None | Some(Value::Null) => None,
400        Some(value) => match value.as_u64() {
401            Some(bytes) => Some(bytes),
402            None => return shape("\"maxImageBytes\" must be a non-negative integer".to_string()),
403        },
404    };
405    options.encryption = parse_optional_encryption(object)?;
406    Ok(options)
407}
408
409/// Builds [`BackupOptions`] from the JSON document `backup()` takes: `{ indexes?: boolean, excludeTables?: string[], lock? }`.
410pub fn parse_backup_options(value: &Value) -> Result<BackupOptions, OptionsError> {
411    let mut options = BackupOptions::default();
412    if value.is_null() {
413        return Ok(options);
414    }
415    let object = as_object(value, "options")?;
416    options.indexes = optional_bool(object, "indexes")?.unwrap_or(false);
417    options.exclude_tables = parse_exclude_tables(object)?;
418    options.encryption = parse_optional_encryption(object)?;
419    if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
420        options.lock = parse_lock(lock)?;
421    }
422    Ok(options)
423}
424
425/// Builds [`RestoreOptions`] from the JSON document `restore()` takes: `{ maxImageBytes?, lock? }`.
426pub fn parse_restore_options(value: &Value) -> Result<RestoreOptions, OptionsError> {
427    let mut options = RestoreOptions::default();
428    if value.is_null() {
429        return Ok(options);
430    }
431    let object = as_object(value, "options")?;
432    options.max_image_bytes = optional_u64(object, "maxImageBytes")?;
433    options.encryption = parse_optional_encryption(object)?;
434    if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
435        options.lock = parse_lock(lock)?;
436    }
437    Ok(options)
438}
439
440/// `{ memoryKiB?, iterations?, parallelism? }` — the Argon2id cost of new `passwordHash` values;
441/// whatever is left out keeps the engine's default.
442fn parse_password_hash(value: &Value) -> Result<PasswordHashParams, OptionsError> {
443    let object = as_object(value, "passwordHash")?;
444    let mut params = PasswordHashParams::default();
445    for (key, target) in [
446        ("memoryKiB", &mut params.memory_kib),
447        ("iterations", &mut params.iterations),
448        ("parallelism", &mut params.parallelism),
449    ] {
450        if let Some(number) = optional_u64(object, key)? {
451            *target = match u32::try_from(number) {
452                Ok(number) => number,
453                Err(_) => return shape(format!("\"passwordHash.{key}\" is too large")),
454            };
455        }
456    }
457    params.validate().map_err(|error| OptionsError::Shape(error.0))?;
458    Ok(params)
459}
460
461/// `encryption?: { keys, keyId?, keyEncoding? }` of a backup/restore/pack/unpack/verify options document.
462fn parse_optional_encryption(object: &serde_json::Map<String, Value>) -> Result<Option<CipherOptions>, OptionsError> {
463    match object.get("encryption").filter(|value| !value.is_null()) {
464        Some(value) => Ok(Some(parse_encryption(value)?)),
465        None => Ok(None),
466    }
467}
468
469/// `excludeTables?: string[]` of a `backup()`/`pack()` options document.
470fn parse_exclude_tables(object: &serde_json::Map<String, Value>) -> Result<Vec<String>, OptionsError> {
471    let Some(raw) = object.get("excludeTables").filter(|value| !value.is_null()) else {
472        return Ok(Vec::new());
473    };
474    let Some(items) = raw.as_array() else {
475        return shape("\"excludeTables\" must be an array of table names");
476    };
477    items
478        .iter()
479        .map(|item| match item.as_str() {
480            Some(name) => Ok(name.to_string()),
481            None => shape("\"excludeTables\" must be an array of table names"),
482        })
483        .collect()
484}
485
486/// `include?: [{ path: string, excludeTables?: string[] }]` of a `pack()` options document.
487fn parse_pack_includes(object: &serde_json::Map<String, Value>) -> Result<Vec<PackInclude>, OptionsError> {
488    let Some(raw) = object.get("include").filter(|value| !value.is_null()) else {
489        return Ok(Vec::new());
490    };
491    let Some(items) = raw.as_array() else {
492        return shape("\"include\" must be an array of { path, excludeTables? }");
493    };
494    let mut includes = Vec::new();
495    for item in items {
496        let entry = as_object(item, "include[]")?;
497        let Some(path) = optional_string(entry, "path")? else {
498            return shape("every \"include\" entry needs a \"path\"");
499        };
500        includes.push(PackInclude {
501            path,
502            encryption: parse_optional_encryption(entry)?,
503            exclude_tables: parse_exclude_tables(entry)?,
504        });
505    }
506    Ok(includes)
507}
508
509fn parse_pack_limits(object: &serde_json::Map<String, Value>) -> Result<PackLimits, OptionsError> {
510    let mut limits = PackLimits::default();
511    let Some(raw) = object.get("limits").filter(|value| !value.is_null()) else {
512        return Ok(limits);
513    };
514    let raw = as_object(raw, "limits")?;
515    let number = |key: &str| -> Result<Option<u64>, OptionsError> {
516        match raw.get(key).filter(|value| !value.is_null()) {
517            None => Ok(None),
518            Some(value) => match value.as_u64() {
519                Some(number) => Ok(Some(number)),
520                None => shape(format!("\"limits.{key}\" must be a non-negative integer")),
521            },
522        }
523    };
524    if let Some(value) = number("maxArchiveBytes")? {
525        limits.max_archive_bytes = value;
526    }
527    if let Some(value) = number("maxTotalBytes")? {
528        limits.max_total_bytes = value;
529    }
530    if let Some(value) = number("maxEntryBytes")? {
531        limits.max_entry_bytes = value;
532    }
533    if let Some(value) = number("maxEntries")? {
534        limits.max_entries = value as usize;
535    }
536    Ok(limits)
537}
538
539/// Builds [`PackLimits`] from `{ limits?: { maxArchiveBytes?, maxTotalBytes?, maxEntryBytes?, maxEntries? } }`.
540pub fn parse_pack_limits_options(value: &Value) -> Result<PackLimits, OptionsError> {
541    if value.is_null() {
542        return Ok(PackLimits::default());
543    }
544    parse_pack_limits(as_object(value, "options")?)
545}
546
547/// Builds [`PackOptions`] from the JSON document `pack()` takes:
548/// `{ indexes?: boolean, excludeTables?: string[], extra?: { [entryName]: string }, limits?, lock? }`.
549pub fn parse_pack_options(value: &Value) -> Result<PackOptions, OptionsError> {
550    let mut options = PackOptions::default();
551    if value.is_null() {
552        return Ok(options);
553    }
554    let object = as_object(value, "options")?;
555    options.indexes = optional_bool(object, "indexes")?.unwrap_or(false);
556    options.exclude_tables = parse_exclude_tables(object)?;
557    options.include = parse_pack_includes(object)?;
558    options.encryption = parse_optional_encryption(object)?;
559    if let Some(extra) = object.get("extra").filter(|value| !value.is_null()) {
560        for (name, content) in as_object(extra, "extra")? {
561            match content.as_str() {
562                Some(text) => options.extra.push((name.clone(), text.as_bytes().to_vec())),
563                None => return shape(format!("\"extra.{name}\" must be a string")),
564            }
565        }
566    }
567    options.limits = parse_pack_limits(object)?;
568    if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
569        options.lock = parse_lock(lock)?;
570    }
571    Ok(options)
572}
573
574/// Builds [`UnpackOptions`] from the JSON document `unpack()` takes: `{ limits?, maxImageBytes?, lock? }`.
575pub fn parse_unpack_options(value: &Value) -> Result<UnpackOptions, OptionsError> {
576    let mut options = UnpackOptions::default();
577    if value.is_null() {
578        return Ok(options);
579    }
580    let object = as_object(value, "options")?;
581    options.encryption = parse_optional_encryption(object)?;
582    if let Some(nested) = object.get("includeEncryption").filter(|value| !value.is_null()) {
583        for (folder, keys) in as_object(nested, "includeEncryption")? {
584            options
585                .include_encryption
586                .push((folder.clone(), parse_encryption(keys)?));
587        }
588    }
589    options.max_image_bytes = optional_u64(object, "maxImageBytes")?;
590    options.limits = parse_pack_limits(object)?;
591    if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
592        options.lock = parse_lock(lock)?;
593    }
594    Ok(options)
595}
596
597/// Builds [`ConvertOptions`] from the JSON document `convertEncryption()` takes:
598/// `{ from?: keys, to?: keys, lock? }`.
599pub fn parse_convert_options(value: &Value) -> Result<ConvertOptions, OptionsError> {
600    let mut options = ConvertOptions::default();
601    if value.is_null() {
602        return Ok(options);
603    }
604    let object = as_object(value, "options")?;
605    if let Some(from) = object.get("from").filter(|value| !value.is_null()) {
606        options.from = Some(parse_key_document(from, "from")?);
607    }
608    if let Some(to) = object.get("to").filter(|value| !value.is_null()) {
609        options.to = Some(parse_key_document(to, "to")?);
610    }
611    if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
612        options.lock = parse_lock(lock)?;
613    }
614    Ok(options)
615}
616
617fn parse_migration_target(raw: &str) -> Result<LayoutKind, OptionsError> {
618    match raw {
619        "flat" => Ok(LayoutKind::Flat),
620        "nested" => Ok(LayoutKind::Nested),
621        other => shape(format!("\"to\" must be \"flat\" or \"nested\" — got \"{other}\"")),
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use serde_json::json;
628
629    use super::*;
630
631    #[test]
632    fn an_absent_document_keeps_every_engine_default() {
633        let options = parse_open_options(&Value::Null).unwrap();
634
635        assert!(!options.readonly);
636        assert_eq!(options.snapshot_every, OpenOptions::default().snapshot_every);
637        assert!(options.cypher.is_none());
638    }
639
640    #[test]
641    fn readonly_snapshot_every_and_lock_reach_the_engine_options() {
642        let options = parse_open_options(&json!({
643            "readonly": true,
644            "snapshotEvery": 5,
645            "lock": { "timeoutMs": 0, "retryIntervalMs": 10 },
646        }))
647        .unwrap();
648
649        assert!(options.readonly);
650        assert_eq!(options.snapshot_every, 5);
651        let lock = options
652            .lock
653            .options()
654            .expect("an object shape asks for a lock, not for skipping");
655        assert_eq!(lock.timeout_ms, Some(0));
656        assert_eq!(lock.retry_interval_ms, Some(10));
657        assert_eq!(lock.heartbeat_ms, None);
658    }
659
660    #[test]
661    fn compaction_reaches_the_engine_in_both_the_thresholds_and_the_false_shape() {
662        let named = parse_open_options(&json!({
663            "compaction": { "deadBytesRatio": 0.5, "minDeadBytes": 200 },
664        }))
665        .unwrap();
666        assert_eq!(
667            named.compaction,
668            Some(CatalogCompaction::Enabled {
669                dead_bytes_ratio: 0.5,
670                min_dead_bytes: 200
671            })
672        );
673
674        let off = parse_open_options(&json!({ "compaction": false })).unwrap();
675        assert_eq!(off.compaction, Some(CatalogCompaction::Disabled));
676
677        // NOTE: absent and null both mean "leave it to the catalog", which is not the same as false.
678        for document in [json!({}), json!({ "compaction": null })] {
679            assert_eq!(parse_open_options(&document).unwrap().compaction, None, "{document}");
680        }
681    }
682
683    #[test]
684    fn a_compaction_ratio_outside_zero_to_one_is_refused_before_the_engine_is_touched() {
685        for ratio in [json!(0), json!(1.5), json!("half")] {
686            let document = json!({ "compaction": { "deadBytesRatio": ratio, "minDeadBytes": 1 } });
687            assert!(parse_open_options(&document).is_err(), "{document}");
688        }
689        assert!(parse_open_options(&json!({ "compaction": true })).is_err());
690    }
691
692    #[test]
693    fn lock_false_asks_the_engine_to_skip_the_writer_lock_and_true_asks_for_the_defaults() {
694        assert!(matches!(
695            parse_open_options(&json!({ "lock": false })).unwrap().lock,
696            LockMode::Skip
697        ));
698
699        // NOTE: absent and null must stay the locked default — skipping is opt-in only.
700        for document in [
701            json!({}),
702            json!({ "lock": null }),
703            json!({ "lock": true }),
704            json!({ "lock": {} }),
705        ] {
706            let lock = parse_open_options(&document).unwrap().lock;
707            assert!(matches!(lock, LockMode::Enabled(_)), "{document} must keep the lock");
708            assert_eq!(lock.options().unwrap().timeout_ms, None);
709        }
710    }
711
712    #[test]
713    fn a_lock_that_is_neither_a_flag_nor_an_options_object_is_refused_as_a_shape_error() {
714        assert_eq!(
715            parse_open_options(&json!({ "lock": "off" })).unwrap_err(),
716            OptionsError::Shape("\"lock\" must be a boolean or an object of lock options".to_string())
717        );
718        assert_eq!(
719            parse_open_options(&json!({ "lock": { "timeoutMs": -1 } })).unwrap_err(),
720            OptionsError::Shape("\"timeoutMs\" must be a non-negative whole number".to_string())
721        );
722    }
723
724    #[test]
725    fn a_base64_keyring_decodes_to_the_exact_bytes_and_keeps_its_write_key() {
726        let options = parse_open_options(&json!({
727            "cypher": {
728                "keys": { "k1": "AAECAw==" },
729                "keyId": "k1",
730                "onMissingKey": "omit",
731            },
732        }))
733        .unwrap();
734
735        let cypher = options.cypher.unwrap();
736        assert_eq!(cypher.keys["k1"], vec![0, 1, 2, 3]);
737        assert_eq!(cypher.key_id.as_deref(), Some("k1"));
738        assert_eq!(cypher.on_missing_key, Some(MissingKeyPolicy::Omit));
739    }
740
741    #[test]
742    fn a_hex_keyring_decodes_only_when_key_encoding_says_so() {
743        let hex = json!({ "cypher": { "keys": { "k1": "00010203" }, "keyEncoding": "hex" } });
744        let options = parse_open_options(&hex).unwrap();
745        assert_eq!(options.cypher.unwrap().keys["k1"], vec![0, 1, 2, 3]);
746
747        // NOTE: "00010203" is also valid base64 — the same text decodes to different bytes.
748        let as_base64 = json!({ "cypher": { "keys": { "k1": "00010203" } } });
749        assert_ne!(
750            parse_open_options(&as_base64).unwrap().cypher.unwrap().keys["k1"],
751            vec![0, 1, 2, 3]
752        );
753    }
754
755    #[test]
756    fn unusable_key_material_is_reported_as_a_key_error_naming_the_key() {
757        let error = parse_open_options(&json!({ "cypher": { "keys": { "k1": "!!!!" } } })).unwrap_err();
758
759        assert_eq!(error, OptionsError::Key("key \"k1\" is not valid base64".to_string()));
760    }
761
762    #[test]
763    fn a_wrong_typed_option_is_refused_as_a_shape_error_naming_the_member() {
764        assert_eq!(
765            parse_open_options(&json!({ "readonly": "yes" })).unwrap_err(),
766            OptionsError::Shape("\"readonly\" must be a boolean".to_string())
767        );
768        assert_eq!(
769            parse_open_options(&json!({ "snapshotEvery": -1 })).unwrap_err(),
770            OptionsError::Shape("\"snapshotEvery\" must be a non-negative whole number".to_string())
771        );
772        assert!(matches!(
773            parse_open_options(&json!({ "cypher": { "keys": {}, "onMissingKey": "shrug" } })).unwrap_err(),
774            OptionsError::Shape(_)
775        ));
776    }
777
778    #[test]
779    fn follow_reaches_the_engine_in_all_three_shapes_the_option_takes() {
780        assert_eq!(parse_open_options(&json!({})).unwrap().follow, FollowOption::Default);
781        assert_eq!(
782            parse_open_options(&json!({ "follow": null })).unwrap().follow,
783            FollowOption::Default
784        );
785        assert_eq!(
786            parse_open_options(&json!({ "follow": false })).unwrap().follow,
787            FollowOption::Off
788        );
789        assert_eq!(
790            parse_open_options(&json!({ "follow": true })).unwrap().follow,
791            FollowOption::Every(Duration::ZERO)
792        );
793        assert_eq!(
794            parse_open_options(&json!({ "follow": { "intervalMs": 250 } }))
795                .unwrap()
796                .follow,
797            FollowOption::Every(Duration::from_millis(250))
798        );
799        assert_eq!(
800            parse_open_options(&json!({ "follow": {} })).unwrap().follow,
801            FollowOption::Every(Duration::ZERO)
802        );
803    }
804
805    #[test]
806    fn a_follow_that_is_neither_a_flag_nor_an_interval_is_refused_as_a_shape_error() {
807        assert!(matches!(
808            parse_open_options(&json!({ "follow": "always" })).unwrap_err(),
809            OptionsError::Shape(_)
810        ));
811        assert!(matches!(
812            parse_open_options(&json!({ "follow": { "intervalMs": -1 } })).unwrap_err(),
813            OptionsError::Shape(_)
814        ));
815    }
816
817    #[test]
818    fn the_layout_triple_reaches_the_engine_unresolved() {
819        assert_eq!(parse_open_options(&json!({})).unwrap().layout, LayoutOptions::default());
820
821        let named = parse_open_options(&json!({
822            "layout": "nested",
823            "metadataFile": "cerebro.json",
824        }))
825        .unwrap();
826        assert_eq!(named.layout.kind, Some(LayoutKind::Nested));
827        assert_eq!(named.layout.metadata_file.as_deref(), Some("cerebro.json"));
828        assert_eq!(named.layout.manifest, None);
829
830        // NOTE: the two names are passed on as given — the engine, not this, decides they conflict.
831        let both = parse_open_options(&json!({ "manifest": "a.json", "metadataFile": "b.json" })).unwrap();
832        assert_eq!(both.layout.manifest.as_deref(), Some("a.json"));
833        assert_eq!(both.layout.metadata_file.as_deref(), Some("b.json"));
834    }
835
836    #[test]
837    fn an_unknown_layout_kind_and_a_non_string_file_name_are_refused_as_shape_errors() {
838        assert_eq!(
839            parse_open_options(&json!({ "layout": "sideways" })).unwrap_err(),
840            OptionsError::Shape("\"layout\" must be \"flat\" or \"nested\" — got \"sideways\"".to_string())
841        );
842        assert_eq!(
843            parse_open_options(&json!({ "metadataFile": 7 })).unwrap_err(),
844            OptionsError::Shape("\"metadataFile\" must be a string".to_string())
845        );
846    }
847
848    #[test]
849    fn a_migration_with_no_document_moves_into_the_nested_arrangement_under_the_engine_lock() {
850        let options = parse_migrate_layout_options(&Value::Null).unwrap();
851
852        assert_eq!(options.to, None, "absent is the engine's own default, not a second one");
853        assert!(!options.dry_run);
854        assert!(options.metadata_file.is_none() && options.from_metadata_file.is_none());
855        assert!(matches!(options.lock, LockMode::Enabled(_)));
856    }
857
858    #[test]
859    fn both_document_name_pairs_reach_the_engine_unresolved_so_it_makes_the_refusal() {
860        let options = parse_migrate_layout_options(&json!({
861            "to": "flat",
862            "fromMetadataFile": "cerebro.json",
863            "fromManifest": "legacy.json",
864            "metadataFile": "hayson.json",
865            "manifestFile": "old.json",
866            "dryRun": true,
867        }))
868        .unwrap();
869
870        assert_eq!(options.to, Some(LayoutKind::Flat));
871        assert_eq!(options.from_metadata_file.as_deref(), Some("cerebro.json"));
872        assert_eq!(options.from_manifest.as_deref(), Some("legacy.json"));
873        assert_eq!(options.metadata_file.as_deref(), Some("hayson.json"));
874        assert_eq!(options.manifest_file.as_deref(), Some("old.json"));
875        assert!(options.dry_run);
876    }
877
878    #[test]
879    fn a_migration_takes_the_same_lock_document_an_open_takes() {
880        let options = parse_migrate_layout_options(&json!({ "lock": { "timeoutMs": 0 } })).unwrap();
881
882        assert_eq!(
883            options.lock.options().and_then(|lock| lock.timeout_ms),
884            Some(0),
885            "a host that owns the wait has to be able to ask for a fail-fast attempt"
886        );
887        let skipped = parse_migrate_layout_options(&json!({ "lock": false })).unwrap();
888        assert!(matches!(skipped.lock, LockMode::Skip));
889    }
890
891    #[test]
892    fn an_unknown_migration_target_is_refused_as_a_shape_error() {
893        assert_eq!(
894            parse_migrate_layout_options(&json!({ "to": "sideways" })).unwrap_err(),
895            OptionsError::Shape("\"to\" must be \"flat\" or \"nested\" — got \"sideways\"".to_string())
896        );
897        assert_eq!(
898            parse_migrate_layout_options(&json!({ "dryRun": "yes" })).unwrap_err(),
899            OptionsError::Shape("\"dryRun\" must be a boolean".to_string())
900        );
901    }
902
903    #[test]
904    fn base64_accepts_the_url_safe_alphabet_and_optional_padding() {
905        assert_eq!(decode_base64("+/8="), decode_base64("-_8"));
906        assert_eq!(decode_base64(""), Some(Vec::new()));
907        assert_eq!(decode_base64("QQ"), Some(vec![b'A']));
908        assert_eq!(decode_base64("QQ=="), Some(vec![b'A']));
909    }
910
911    #[test]
912    fn base64_refuses_a_length_and_a_symbol_that_cannot_be_base64() {
913        assert_eq!(decode_base64("A"), None);
914        assert_eq!(decode_base64("QQ*="), None);
915    }
916
917    #[test]
918    fn hex_round_trips_both_cases_and_refuses_odd_or_non_hex_input() {
919        assert_eq!(decode_hex("DEADbeef"), Some(vec![0xde, 0xad, 0xbe, 0xef]));
920        assert_eq!(decode_hex("abc"), None);
921        assert_eq!(decode_hex("zz"), None);
922    }
923
924    #[test]
925    fn pack_options_carry_extra_entries_limits_and_the_lock() {
926        let options = parse_pack_options(&json!({
927            "indexes": true,
928            "excludeTables": ["users"],
929            "extra": { "gly.json": "{}" },
930            "limits": { "maxEntries": 5, "maxEntryBytes": 10 },
931        }))
932        .unwrap();
933
934        assert!(options.indexes);
935        assert_eq!(options.exclude_tables, vec!["users".to_string()]);
936        assert_eq!(options.extra, vec![("gly.json".to_string(), b"{}".to_vec())]);
937        assert_eq!(options.limits.max_entries, 5);
938        assert_eq!(options.limits.max_entry_bytes, 10);
939        assert_eq!(
940            options.limits.max_archive_bytes,
941            PackLimits::default().max_archive_bytes
942        );
943        assert!(parse_pack_options(&json!({ "extra": { "a": 1 } })).is_err());
944        assert!(parse_unpack_options(&json!({ "limits": { "maxEntries": -1 } })).is_err());
945        assert_eq!(
946            parse_unpack_options(&Value::Null).unwrap().limits,
947            PackLimits::default()
948        );
949    }
950
951    #[test]
952    fn restore_options_carry_the_image_limit_and_the_keys() {
953        let options = parse_restore_options(&json!({
954            "maxImageBytes": 7,
955            "encryption": { "keys": { "k1": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" } },
956        }))
957        .unwrap();
958
959        assert_eq!(options.max_image_bytes, Some(7));
960        assert!(options.encryption.is_some());
961        assert!(parse_restore_options(&json!({ "maxImageBytes": -1 })).is_err());
962    }
963
964    #[test]
965    fn convert_options_read_both_key_documents_and_the_lock() {
966        let keys = json!({ "keys": { "k1": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" } });
967        let options = parse_convert_options(&json!({ "from": keys, "to": keys, "lock": false })).unwrap();
968
969        assert!(options.from.is_some() && options.to.is_some());
970        assert!(matches!(options.lock, LockMode::Skip));
971        assert!(parse_convert_options(&json!({ "to": { "nokeys": 1 } })).is_err());
972        assert!(parse_convert_options(&Value::Null).unwrap().from.is_none());
973    }
974}