hayson-binding-shared 0.18.0

Shared error mapping, JSON shapes, options and predicate parsing for hayson's NAPI and WASM language bindings
Documentation
//! Cypher key material a host process was started with — the file one environment variable names,
//! or the document another one holds inline.
//!
//! The document is the `cypher` sub-object of the `open()` options document, parsed by
//! [`crate::options::parse_cypher`] and nothing else, so a key file reads exactly the same here,
//! in `hayson-browser` and in a `open({ cypher })` call from JS.
//!
//! Validation happens at load, not at the first sealed record: a key file that does not parse, or
//! material that is not 32 bytes, is a startup failure with a message naming the variable it came
//! from. The alternative — a process running with silently no keys — turns one bad path into a
//! read that refuses hours later, somewhere else entirely.

use std::fmt;
use std::fs;
use std::path::Path;

use hayson_engine::crypto::{CipherOptions, FieldCipher};
use serde_json::Value;

use crate::options::{parse_cypher, OptionsError};

/// The pair of environment variables one host reads its keys from — `<FILE>` naming a JSON file,
/// `<INLINE>` holding the same document as text. By convention `HAYSON_<APP>_CYPHER_KEY_FILE` and
/// `HAYSON_<APP>_CYPHER_KEYS`.
#[derive(Debug, Clone, Copy)]
pub struct CypherKeyVars {
    pub file: &'static str,
    pub inline: &'static str,
}

#[derive(Debug, PartialEq, Eq)]
pub struct KeyringError(String);

impl KeyringError {
    pub fn message(&self) -> &str {
        &self.0
    }
}

impl fmt::Display for KeyringError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for KeyringError {}

/// Key material a process holds, and where it came from. The ids are readable back; the material
/// is not — a host printing what it loaded prints [`CypherKeyring::key_ids`] and never more.
#[derive(Debug, Clone)]
pub struct CypherKeyring {
    options: CipherOptions,
    source: String,
}

impl CypherKeyring {
    /// The ids on hand, sorted. Never the material.
    pub fn key_ids(&self) -> Vec<String> {
        let mut ids: Vec<String> = self.options.keys.keys().cloned().collect();
        ids.sort();
        ids
    }

    /// Which variable, and which file, these keys came from — for the line a host prints at start.
    pub fn source(&self) -> &str {
        &self.source
    }

    pub fn options(&self) -> &CipherOptions {
        &self.options
    }

    pub fn into_options(self) -> CipherOptions {
        self.options
    }
}

fn refused(message: impl Into<String>) -> KeyringError {
    KeyringError(message.into())
}

fn describe(source: &str, error: OptionsError) -> KeyringError {
    let reason = match error {
        OptionsError::Shape(message) | OptionsError::Key(message) => message,
    };
    refused(format!("{source} does not hold usable cypher options: {reason}"))
}

/// Reads a `cypher` document out of JSON text, naming `source` in every refusal.
///
/// The options are taken all the way to a [`FieldCipher`] and the cipher thrown away again:
/// parsing only decodes the material, and it is the cipher that judges whether 32 bytes came out
/// of it and whether `keyId` names a key that is actually there.
pub fn from_json_text(text: &str, source: impl Into<String>) -> Result<CypherKeyring, KeyringError> {
    let source = source.into();
    let document: Value =
        serde_json::from_str(text).map_err(|error| refused(format!("{source} is not valid JSON: {error}")))?;
    let options = parse_cypher(&document).map_err(|error| describe(&source, error))?;
    FieldCipher::new(options.clone())
        .map_err(|error| refused(format!("{source} holds unusable cypher keys: {}", error.reason)))?;
    Ok(CypherKeyring { options, source })
}

/// The keys `vars` names, or `None` when neither variable is set to anything. The file wins over
/// the inline document, so a host can override a baked-in environment by pointing at a file.
///
/// `lookup` is the environment, passed in rather than read here so a test can describe one without
/// touching the process it runs in.
pub fn ambient(
    vars: CypherKeyVars,
    lookup: impl Fn(&str) -> Option<String>,
) -> Result<Option<CypherKeyring>, KeyringError> {
    if let Some(path) = lookup(vars.file).filter(|path| !path.is_empty()) {
        return from_file(Path::new(&path), vars.file).map(Some);
    }
    match lookup(vars.inline).filter(|text| !text.is_empty()) {
        Some(text) => from_json_text(&text, vars.inline).map(Some),
        None => Ok(None),
    }
}

/// The keys the JSON file at `path` holds. `named_by` is the flag or variable that pointed here,
/// so a refusal says how the process was asked for this file rather than only which file it was.
pub fn from_file(path: &Path, named_by: &str) -> Result<CypherKeyring, KeyringError> {
    let shown = path.display();
    let text = fs::read_to_string(path).map_err(|error| {
        refused(format!(
            "{named_by} names \"{shown}\", which could not be read: {error}"
        ))
    })?;
    from_json_text(&text, format!("{named_by}=\"{shown}\""))
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use serde_json::json;

    use super::*;

    const VARS: CypherKeyVars = CypherKeyVars {
        file: "HAYSON_TEST_CYPHER_KEY_FILE",
        inline: "HAYSON_TEST_CYPHER_KEYS",
    };

    fn key_document() -> String {
        json!({ "keys": { "k1": "01".repeat(32) }, "keyEncoding": "hex" }).to_string()
    }

    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
        let map: HashMap<String, String> = pairs
            .iter()
            .map(|(name, value)| ((*name).to_string(), (*value).to_string()))
            .collect();
        move |name: &str| map.get(name).cloned()
    }

    #[test]
    fn an_environment_naming_neither_variable_leaves_the_process_without_keys() {
        let keyring = ambient(VARS, env(&[])).expect("an empty environment is not an error");

        assert!(keyring.is_none());
    }

    #[test]
    fn an_inline_document_is_read_and_only_its_ids_are_readable_back() {
        let keyring = ambient(VARS, env(&[(VARS.inline, &key_document())]))
            .expect("the document is usable")
            .expect("the variable was set");

        assert_eq!(keyring.key_ids(), vec!["k1".to_string()]);
        assert_eq!(keyring.source(), VARS.inline);
        assert_eq!(keyring.options().keys["k1"], vec![1u8; 32]);
    }

    #[test]
    fn the_file_variable_wins_and_is_not_fallen_back_on() {
        let error = ambient(
            VARS,
            env(&[(VARS.file, "/no/such/keys.json"), (VARS.inline, &key_document())]),
        )
        .expect_err("the file was named, so a usable inline document must not paper over it");

        assert!(error.message().contains(VARS.file), "{error}");
    }

    #[test]
    fn a_file_that_cannot_be_read_names_the_variable_that_pointed_at_it() {
        let error = ambient(VARS, env(&[(VARS.file, "/no/such/keys.json")])).expect_err("the file is not there");

        assert!(error.message().contains(VARS.file), "{error}");
        assert!(error.message().contains("/no/such/keys.json"), "{error}");
    }

    #[test]
    fn material_of_the_wrong_length_is_refused_at_load_not_at_the_first_read() {
        let short = json!({ "keys": { "k1": "AAAA" } }).to_string();

        let error = ambient(VARS, env(&[(VARS.inline, &short)])).expect_err("3 bytes cannot be an AES-256 key");

        assert!(error.message().contains("32 bytes"), "{error}");
    }

    #[test]
    fn a_key_id_naming_no_key_is_refused_at_load() {
        let document = json!({ "keys": { "k1": "01".repeat(32) }, "keyEncoding": "hex", "keyId": "k9" }).to_string();

        let error = ambient(VARS, env(&[(VARS.inline, &document)])).expect_err("k9 is not among the keys");

        assert!(error.message().contains("k9"), "{error}");
    }

    #[test]
    fn a_document_that_is_not_json_at_all_is_refused_by_source() {
        let error = from_json_text("{ not json", "SOMEWHERE").expect_err("that is not JSON");

        assert!(error.message().starts_with("SOMEWHERE is not valid JSON"), "{error}");
    }

    #[test]
    fn the_on_missing_key_policy_is_carried_through_untouched() {
        let document = json!({ "keys": { "k1": "01".repeat(32) }, "keyEncoding": "hex", "onMissingKey": "omit" });

        let keyring = from_json_text(&document.to_string(), "SOMEWHERE").expect("the document is usable");

        assert_eq!(
            keyring.options().on_missing_key,
            Some(hayson_engine::crypto::MissingKeyPolicy::Omit),
            "a host that wants the engine's default must not have one forced on it here"
        );
    }
}