1use std::collections::HashMap;
2
3use std::time::Duration;
4
5use hayson_engine::catalog::CatalogCompaction;
6use hayson_engine::crypto::{CipherOptions, MissingKeyPolicy};
7use hayson_engine::database::{FollowOption, LayoutKind, LayoutMigrationOptions, LayoutOptions, LockMode, OpenOptions};
8use hayson_engine::lock::{GraceServed, LockOptions};
9use serde_json::Value;
10
11#[derive(Debug, PartialEq, Eq)]
13pub enum OptionsError {
14 Shape(String),
16 Key(String),
18}
19
20fn shape<T>(message: impl Into<String>) -> Result<T, OptionsError> {
21 Err(OptionsError::Shape(message.into()))
22}
23
24fn as_object<'a>(value: &'a Value, what: &str) -> Result<&'a serde_json::Map<String, Value>, OptionsError> {
25 match value.as_object() {
26 Some(object) => Ok(object),
27 None => shape(format!("\"{what}\" must be a JSON object")),
28 }
29}
30
31fn optional_bool(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<bool>, OptionsError> {
32 match object.get(key) {
33 None | Some(Value::Null) => Ok(None),
34 Some(Value::Bool(value)) => Ok(Some(*value)),
35 Some(_) => shape(format!("\"{key}\" must be a boolean")),
36 }
37}
38
39fn optional_u64(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<u64>, OptionsError> {
40 match object.get(key) {
41 None | Some(Value::Null) => Ok(None),
42 Some(Value::Number(number)) => match number.as_u64() {
43 Some(value) => Ok(Some(value)),
44 None => shape(format!("\"{key}\" must be a non-negative whole number")),
45 },
46 Some(_) => shape(format!("\"{key}\" must be a number")),
47 }
48}
49
50fn optional_i64(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<i64>, OptionsError> {
51 match object.get(key) {
52 None | Some(Value::Null) => Ok(None),
53 Some(Value::Number(number)) => match number.as_i64() {
54 Some(value) => Ok(Some(value)),
55 None => shape(format!("\"{key}\" must be a whole number")),
56 },
57 Some(_) => shape(format!("\"{key}\" must be a number")),
58 }
59}
60
61fn optional_string(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<String>, OptionsError> {
62 match object.get(key) {
63 None | Some(Value::Null) => Ok(None),
64 Some(Value::String(value)) => Ok(Some(value.clone())),
65 Some(_) => shape(format!("\"{key}\" must be a string")),
66 }
67}
68
69fn parse_lock(value: &Value) -> Result<LockMode, OptionsError> {
74 match value {
75 Value::Bool(false) => Ok(LockMode::Skip),
76 Value::Bool(true) => Ok(LockMode::Enabled(LockOptions::default())),
77 Value::Object(object) => Ok(LockMode::Enabled(LockOptions {
78 stale_after_ms: optional_u64(object, "staleAfterMs")?,
79 heartbeat_ms: optional_u64(object, "heartbeatMs")?,
80 timeout_ms: optional_u64(object, "timeoutMs")?,
81 retry_interval_ms: optional_u64(object, "retryIntervalMs")?,
82 grace_served: parse_grace_served(object)?,
83 })),
84 _ => shape("\"lock\" must be a boolean or an object of lock options"),
85 }
86}
87
88fn parse_grace_served(object: &serde_json::Map<String, Value>) -> Result<Option<GraceServed>, OptionsError> {
96 let served = match object.get("graceServed") {
97 None | Some(Value::Null) => return Ok(None),
98 Some(value) => as_object(value, "graceServed")?,
99 };
100 let Some(owner) = optional_string(served, "owner")? else {
101 return shape("\"graceServed\" must name the \"owner\" whose grace it waited out");
102 };
103 let Some(heartbeat_at) = optional_i64(served, "heartbeatAt")? else {
104 return shape("\"graceServed\" must name the \"heartbeatAt\" whose grace it waited out");
105 };
106 Ok(GraceServed::parse(&owner, heartbeat_at))
107}
108
109fn parse_follow(value: &Value) -> Result<FollowOption, OptionsError> {
113 match value {
114 Value::Bool(false) => Ok(FollowOption::Off),
115 Value::Bool(true) => Ok(FollowOption::Every(Duration::ZERO)),
116 Value::Object(object) => match optional_u64(object, "intervalMs")? {
117 Some(interval_ms) => Ok(FollowOption::Every(Duration::from_millis(interval_ms))),
118 None => Ok(FollowOption::Every(Duration::ZERO)),
119 },
120 _ => shape("\"follow\" must be a boolean or an object with \"intervalMs\""),
121 }
122}
123
124fn parse_compaction(value: &Value) -> Result<CatalogCompaction, OptionsError> {
128 match value {
129 Value::Bool(false) => Ok(CatalogCompaction::Disabled),
130 Value::Object(object) => {
131 let ratio = match object.get("deadBytesRatio") {
132 None | Some(Value::Null) => None,
133 Some(Value::Number(number)) => number.as_f64(),
134 Some(_) => return shape("\"deadBytesRatio\" must be a number"),
135 };
136 let Some(dead_bytes_ratio) = ratio.filter(|ratio| *ratio > 0.0 && *ratio <= 1.0) else {
137 return shape("\"deadBytesRatio\" must be a number greater than 0 and at most 1");
138 };
139 let min_dead_bytes = optional_u64(object, "minDeadBytes")?.unwrap_or(0);
140 Ok(CatalogCompaction::Enabled {
141 dead_bytes_ratio,
142 min_dead_bytes,
143 })
144 }
145 _ => shape("\"compaction\" must be false or an object of compaction thresholds"),
146 }
147}
148
149fn parse_layout_kind(raw: &str) -> Result<LayoutKind, OptionsError> {
150 match raw {
151 "flat" => Ok(LayoutKind::Flat),
152 "nested" => Ok(LayoutKind::Nested),
153 other => shape(format!("\"layout\" must be \"flat\" or \"nested\" — got \"{other}\"")),
154 }
155}
156
157fn parse_layout(object: &serde_json::Map<String, Value>) -> Result<LayoutOptions, OptionsError> {
161 Ok(LayoutOptions {
162 kind: match optional_string(object, "layout")? {
163 Some(raw) => Some(parse_layout_kind(&raw)?),
164 None => None,
165 },
166 metadata_file: optional_string(object, "metadataFile")?,
167 manifest: optional_string(object, "manifest")?,
168 })
169}
170
171fn parse_missing_key_policy(raw: &str) -> Result<MissingKeyPolicy, OptionsError> {
172 match raw {
173 "throw" => Ok(MissingKeyPolicy::Throw),
174 "omit" => Ok(MissingKeyPolicy::Omit),
175 "raw" => Ok(MissingKeyPolicy::Raw),
176 other => shape(format!(
177 "\"onMissingKey\" must be one of \"throw\", \"omit\", \"raw\" — got \"{other}\""
178 )),
179 }
180}
181
182fn decode_key(id: &str, raw: &str, encoding: KeyEncoding) -> Result<Vec<u8>, OptionsError> {
184 let decoded = match encoding {
185 KeyEncoding::Base64 => decode_base64(raw),
186 KeyEncoding::Hex => decode_hex(raw),
187 };
188 decoded.ok_or_else(|| OptionsError::Key(format!("key \"{id}\" is not valid {}", encoding.as_str())))
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum KeyEncoding {
193 Base64,
194 Hex,
195}
196
197impl KeyEncoding {
198 fn as_str(self) -> &'static str {
199 match self {
200 KeyEncoding::Base64 => "base64",
201 KeyEncoding::Hex => "hex",
202 }
203 }
204}
205
206fn base64_symbol(byte: u8) -> Option<u8> {
207 match byte {
208 b'A'..=b'Z' => Some(byte - b'A'),
209 b'a'..=b'z' => Some(byte - b'a' + 26),
210 b'0'..=b'9' => Some(byte - b'0' + 52),
211 b'+' | b'-' => Some(62),
213 b'/' | b'_' => Some(63),
214 _ => None,
215 }
216}
217
218pub fn decode_base64(raw: &str) -> Option<Vec<u8>> {
221 let symbols: &[u8] = raw.trim_end_matches('=').as_bytes();
222 if symbols.len() % 4 == 1 {
223 return None;
224 }
225 let mut out = Vec::with_capacity(symbols.len() * 3 / 4);
226 let mut accumulator: u32 = 0;
227 let mut bits = 0u32;
228 for byte in symbols {
229 accumulator = (accumulator << 6) | u32::from(base64_symbol(*byte)?);
230 bits += 6;
231 if bits >= 8 {
232 bits -= 8;
233 out.push((accumulator >> bits) as u8);
234 }
235 }
236 if accumulator & ((1 << bits) - 1) != 0 {
238 return None;
239 }
240 Some(out)
241}
242
243pub fn decode_hex(raw: &str) -> Option<Vec<u8>> {
245 let digits = raw.as_bytes();
246 if !digits.len().is_multiple_of(2) {
247 return None;
248 }
249 let mut out = Vec::with_capacity(digits.len() / 2);
250 for pair in digits.chunks_exact(2) {
251 let high = (pair[0] as char).to_digit(16)?;
252 let low = (pair[1] as char).to_digit(16)?;
253 out.push((high * 16 + low) as u8);
254 }
255 Some(out)
256}
257
258pub fn parse_cypher(value: &Value) -> Result<CipherOptions, OptionsError> {
262 let object = as_object(value, "cypher")?;
263 let encoding = match optional_string(object, "keyEncoding")?.as_deref() {
264 None | Some("base64") => KeyEncoding::Base64,
265 Some("hex") => KeyEncoding::Hex,
266 Some(other) => {
267 return shape(format!(
268 "\"keyEncoding\" must be \"base64\" or \"hex\" — got \"{other}\""
269 ))
270 }
271 };
272
273 let raw_keys = match object.get("keys") {
274 Some(keys) => as_object(keys, "cypher.keys")?,
275 None => return shape("\"cypher\" needs a \"keys\" object"),
276 };
277
278 let mut keys = HashMap::with_capacity(raw_keys.len());
279 for (id, material) in raw_keys {
280 let Some(material) = material.as_str() else {
281 return shape(format!("key \"{id}\" must be a string"));
282 };
283 keys.insert(id.clone(), decode_key(id, material, encoding)?);
284 }
285
286 let on_missing_key = match optional_string(object, "onMissingKey")? {
287 Some(raw) => Some(parse_missing_key_policy(&raw)?),
288 None => None,
289 };
290
291 Ok(CipherOptions {
292 keys,
293 key_id: optional_string(object, "keyId")?,
294 on_missing_key,
295 })
296}
297
298pub fn parse_open_options(value: &Value) -> Result<OpenOptions, OptionsError> {
301 let mut options = OpenOptions::default();
302 if value.is_null() {
303 return Ok(options);
304 }
305 let object = as_object(value, "options")?;
306
307 if let Some(readonly) = optional_bool(object, "readonly")? {
308 options.readonly = readonly;
309 }
310 if let Some(snapshot_every) = optional_u64(object, "snapshotEvery")? {
311 options.snapshot_every = snapshot_every;
312 }
313 if let Some(compaction) = object.get("compaction").filter(|value| !value.is_null()) {
314 options.compaction = Some(parse_compaction(compaction)?);
315 }
316 if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
317 options.lock = parse_lock(lock)?;
318 }
319 if let Some(cypher) = object.get("cypher").filter(|value| !value.is_null()) {
320 options.cypher = Some(parse_cypher(cypher)?);
321 }
322 if let Some(follow) = object.get("follow").filter(|value| !value.is_null()) {
323 options.follow = parse_follow(follow)?;
324 }
325 options.layout = parse_layout(object)?;
326
327 Ok(options)
328}
329
330pub fn parse_migrate_layout_options(value: &Value) -> Result<LayoutMigrationOptions, OptionsError> {
339 let mut options = LayoutMigrationOptions::default();
340 if value.is_null() {
341 return Ok(options);
342 }
343 let object = as_object(value, "options")?;
344
345 options.to = match optional_string(object, "to")? {
346 Some(raw) => Some(parse_migration_target(&raw)?),
347 None => None,
348 };
349 options.from_metadata_file = optional_string(object, "fromMetadataFile")?;
350 options.from_manifest = optional_string(object, "fromManifest")?;
351 options.metadata_file = optional_string(object, "metadataFile")?;
352 options.manifest_file = optional_string(object, "manifestFile")?;
353 options.dry_run = optional_bool(object, "dryRun")?.unwrap_or(false);
354 if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
355 options.lock = parse_lock(lock)?;
356 }
357
358 Ok(options)
359}
360
361fn parse_migration_target(raw: &str) -> Result<LayoutKind, OptionsError> {
362 match raw {
363 "flat" => Ok(LayoutKind::Flat),
364 "nested" => Ok(LayoutKind::Nested),
365 other => shape(format!("\"to\" must be \"flat\" or \"nested\" — got \"{other}\"")),
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use serde_json::json;
372
373 use super::*;
374
375 #[test]
376 fn an_absent_document_keeps_every_engine_default() {
377 let options = parse_open_options(&Value::Null).unwrap();
378
379 assert!(!options.readonly);
380 assert_eq!(options.snapshot_every, OpenOptions::default().snapshot_every);
381 assert!(options.cypher.is_none());
382 }
383
384 #[test]
385 fn readonly_snapshot_every_and_lock_reach_the_engine_options() {
386 let options = parse_open_options(&json!({
387 "readonly": true,
388 "snapshotEvery": 5,
389 "lock": { "timeoutMs": 0, "retryIntervalMs": 10 },
390 }))
391 .unwrap();
392
393 assert!(options.readonly);
394 assert_eq!(options.snapshot_every, 5);
395 let lock = options
396 .lock
397 .options()
398 .expect("an object shape asks for a lock, not for skipping");
399 assert_eq!(lock.timeout_ms, Some(0));
400 assert_eq!(lock.retry_interval_ms, Some(10));
401 assert_eq!(lock.heartbeat_ms, None);
402 }
403
404 #[test]
405 fn compaction_reaches_the_engine_in_both_the_thresholds_and_the_false_shape() {
406 let named = parse_open_options(&json!({
407 "compaction": { "deadBytesRatio": 0.5, "minDeadBytes": 200 },
408 }))
409 .unwrap();
410 assert_eq!(
411 named.compaction,
412 Some(CatalogCompaction::Enabled {
413 dead_bytes_ratio: 0.5,
414 min_dead_bytes: 200
415 })
416 );
417
418 let off = parse_open_options(&json!({ "compaction": false })).unwrap();
419 assert_eq!(off.compaction, Some(CatalogCompaction::Disabled));
420
421 for document in [json!({}), json!({ "compaction": null })] {
423 assert_eq!(parse_open_options(&document).unwrap().compaction, None, "{document}");
424 }
425 }
426
427 #[test]
428 fn a_compaction_ratio_outside_zero_to_one_is_refused_before_the_engine_is_touched() {
429 for ratio in [json!(0), json!(1.5), json!("half")] {
430 let document = json!({ "compaction": { "deadBytesRatio": ratio, "minDeadBytes": 1 } });
431 assert!(parse_open_options(&document).is_err(), "{document}");
432 }
433 assert!(parse_open_options(&json!({ "compaction": true })).is_err());
434 }
435
436 #[test]
437 fn lock_false_asks_the_engine_to_skip_the_writer_lock_and_true_asks_for_the_defaults() {
438 assert!(matches!(
439 parse_open_options(&json!({ "lock": false })).unwrap().lock,
440 LockMode::Skip
441 ));
442
443 for document in [
445 json!({}),
446 json!({ "lock": null }),
447 json!({ "lock": true }),
448 json!({ "lock": {} }),
449 ] {
450 let lock = parse_open_options(&document).unwrap().lock;
451 assert!(matches!(lock, LockMode::Enabled(_)), "{document} must keep the lock");
452 assert_eq!(lock.options().unwrap().timeout_ms, None);
453 }
454 }
455
456 #[test]
457 fn a_lock_that_is_neither_a_flag_nor_an_options_object_is_refused_as_a_shape_error() {
458 assert_eq!(
459 parse_open_options(&json!({ "lock": "off" })).unwrap_err(),
460 OptionsError::Shape("\"lock\" must be a boolean or an object of lock options".to_string())
461 );
462 assert_eq!(
463 parse_open_options(&json!({ "lock": { "timeoutMs": -1 } })).unwrap_err(),
464 OptionsError::Shape("\"timeoutMs\" must be a non-negative whole number".to_string())
465 );
466 }
467
468 #[test]
469 fn a_base64_keyring_decodes_to_the_exact_bytes_and_keeps_its_write_key() {
470 let options = parse_open_options(&json!({
471 "cypher": {
472 "keys": { "k1": "AAECAw==" },
473 "keyId": "k1",
474 "onMissingKey": "omit",
475 },
476 }))
477 .unwrap();
478
479 let cypher = options.cypher.unwrap();
480 assert_eq!(cypher.keys["k1"], vec![0, 1, 2, 3]);
481 assert_eq!(cypher.key_id.as_deref(), Some("k1"));
482 assert_eq!(cypher.on_missing_key, Some(MissingKeyPolicy::Omit));
483 }
484
485 #[test]
486 fn a_hex_keyring_decodes_only_when_key_encoding_says_so() {
487 let hex = json!({ "cypher": { "keys": { "k1": "00010203" }, "keyEncoding": "hex" } });
488 let options = parse_open_options(&hex).unwrap();
489 assert_eq!(options.cypher.unwrap().keys["k1"], vec![0, 1, 2, 3]);
490
491 let as_base64 = json!({ "cypher": { "keys": { "k1": "00010203" } } });
493 assert_ne!(
494 parse_open_options(&as_base64).unwrap().cypher.unwrap().keys["k1"],
495 vec![0, 1, 2, 3]
496 );
497 }
498
499 #[test]
500 fn unusable_key_material_is_reported_as_a_key_error_naming_the_key() {
501 let error = parse_open_options(&json!({ "cypher": { "keys": { "k1": "!!!!" } } })).unwrap_err();
502
503 assert_eq!(error, OptionsError::Key("key \"k1\" is not valid base64".to_string()));
504 }
505
506 #[test]
507 fn a_wrong_typed_option_is_refused_as_a_shape_error_naming_the_member() {
508 assert_eq!(
509 parse_open_options(&json!({ "readonly": "yes" })).unwrap_err(),
510 OptionsError::Shape("\"readonly\" must be a boolean".to_string())
511 );
512 assert_eq!(
513 parse_open_options(&json!({ "snapshotEvery": -1 })).unwrap_err(),
514 OptionsError::Shape("\"snapshotEvery\" must be a non-negative whole number".to_string())
515 );
516 assert!(matches!(
517 parse_open_options(&json!({ "cypher": { "keys": {}, "onMissingKey": "shrug" } })).unwrap_err(),
518 OptionsError::Shape(_)
519 ));
520 }
521
522 #[test]
523 fn follow_reaches_the_engine_in_all_three_shapes_the_option_takes() {
524 assert_eq!(parse_open_options(&json!({})).unwrap().follow, FollowOption::Default);
525 assert_eq!(
526 parse_open_options(&json!({ "follow": null })).unwrap().follow,
527 FollowOption::Default
528 );
529 assert_eq!(
530 parse_open_options(&json!({ "follow": false })).unwrap().follow,
531 FollowOption::Off
532 );
533 assert_eq!(
534 parse_open_options(&json!({ "follow": true })).unwrap().follow,
535 FollowOption::Every(Duration::ZERO)
536 );
537 assert_eq!(
538 parse_open_options(&json!({ "follow": { "intervalMs": 250 } }))
539 .unwrap()
540 .follow,
541 FollowOption::Every(Duration::from_millis(250))
542 );
543 assert_eq!(
544 parse_open_options(&json!({ "follow": {} })).unwrap().follow,
545 FollowOption::Every(Duration::ZERO)
546 );
547 }
548
549 #[test]
550 fn a_follow_that_is_neither_a_flag_nor_an_interval_is_refused_as_a_shape_error() {
551 assert!(matches!(
552 parse_open_options(&json!({ "follow": "always" })).unwrap_err(),
553 OptionsError::Shape(_)
554 ));
555 assert!(matches!(
556 parse_open_options(&json!({ "follow": { "intervalMs": -1 } })).unwrap_err(),
557 OptionsError::Shape(_)
558 ));
559 }
560
561 #[test]
562 fn the_layout_triple_reaches_the_engine_unresolved() {
563 assert_eq!(parse_open_options(&json!({})).unwrap().layout, LayoutOptions::default());
564
565 let named = parse_open_options(&json!({
566 "layout": "nested",
567 "metadataFile": "cerebro.json",
568 }))
569 .unwrap();
570 assert_eq!(named.layout.kind, Some(LayoutKind::Nested));
571 assert_eq!(named.layout.metadata_file.as_deref(), Some("cerebro.json"));
572 assert_eq!(named.layout.manifest, None);
573
574 let both = parse_open_options(&json!({ "manifest": "a.json", "metadataFile": "b.json" })).unwrap();
576 assert_eq!(both.layout.manifest.as_deref(), Some("a.json"));
577 assert_eq!(both.layout.metadata_file.as_deref(), Some("b.json"));
578 }
579
580 #[test]
581 fn an_unknown_layout_kind_and_a_non_string_file_name_are_refused_as_shape_errors() {
582 assert_eq!(
583 parse_open_options(&json!({ "layout": "sideways" })).unwrap_err(),
584 OptionsError::Shape("\"layout\" must be \"flat\" or \"nested\" — got \"sideways\"".to_string())
585 );
586 assert_eq!(
587 parse_open_options(&json!({ "metadataFile": 7 })).unwrap_err(),
588 OptionsError::Shape("\"metadataFile\" must be a string".to_string())
589 );
590 }
591
592 #[test]
593 fn a_migration_with_no_document_moves_into_the_nested_arrangement_under_the_engine_lock() {
594 let options = parse_migrate_layout_options(&Value::Null).unwrap();
595
596 assert_eq!(options.to, None, "absent is the engine's own default, not a second one");
597 assert!(!options.dry_run);
598 assert!(options.metadata_file.is_none() && options.from_metadata_file.is_none());
599 assert!(matches!(options.lock, LockMode::Enabled(_)));
600 }
601
602 #[test]
603 fn both_document_name_pairs_reach_the_engine_unresolved_so_it_makes_the_refusal() {
604 let options = parse_migrate_layout_options(&json!({
605 "to": "flat",
606 "fromMetadataFile": "cerebro.json",
607 "fromManifest": "legacy.json",
608 "metadataFile": "hayson.json",
609 "manifestFile": "old.json",
610 "dryRun": true,
611 }))
612 .unwrap();
613
614 assert_eq!(options.to, Some(LayoutKind::Flat));
615 assert_eq!(options.from_metadata_file.as_deref(), Some("cerebro.json"));
616 assert_eq!(options.from_manifest.as_deref(), Some("legacy.json"));
617 assert_eq!(options.metadata_file.as_deref(), Some("hayson.json"));
618 assert_eq!(options.manifest_file.as_deref(), Some("old.json"));
619 assert!(options.dry_run);
620 }
621
622 #[test]
623 fn a_migration_takes_the_same_lock_document_an_open_takes() {
624 let options = parse_migrate_layout_options(&json!({ "lock": { "timeoutMs": 0 } })).unwrap();
625
626 assert_eq!(
627 options.lock.options().and_then(|lock| lock.timeout_ms),
628 Some(0),
629 "a host that owns the wait has to be able to ask for a fail-fast attempt"
630 );
631 let skipped = parse_migrate_layout_options(&json!({ "lock": false })).unwrap();
632 assert!(matches!(skipped.lock, LockMode::Skip));
633 }
634
635 #[test]
636 fn an_unknown_migration_target_is_refused_as_a_shape_error() {
637 assert_eq!(
638 parse_migrate_layout_options(&json!({ "to": "sideways" })).unwrap_err(),
639 OptionsError::Shape("\"to\" must be \"flat\" or \"nested\" — got \"sideways\"".to_string())
640 );
641 assert_eq!(
642 parse_migrate_layout_options(&json!({ "dryRun": "yes" })).unwrap_err(),
643 OptionsError::Shape("\"dryRun\" must be a boolean".to_string())
644 );
645 }
646
647 #[test]
648 fn base64_accepts_the_url_safe_alphabet_and_optional_padding() {
649 assert_eq!(decode_base64("+/8="), decode_base64("-_8"));
650 assert_eq!(decode_base64(""), Some(Vec::new()));
651 assert_eq!(decode_base64("QQ"), Some(vec![b'A']));
652 assert_eq!(decode_base64("QQ=="), Some(vec![b'A']));
653 }
654
655 #[test]
656 fn base64_refuses_a_length_and_a_symbol_that_cannot_be_base64() {
657 assert_eq!(decode_base64("A"), None);
658 assert_eq!(decode_base64("QQ*="), None);
659 }
660
661 #[test]
662 fn hex_round_trips_both_cases_and_refuses_odd_or_non_hex_input() {
663 assert_eq!(decode_hex("DEADbeef"), Some(vec![0xde, 0xad, 0xbe, 0xef]));
664 assert_eq!(decode_hex("abc"), None);
665 assert_eq!(decode_hex("zz"), None);
666 }
667}