app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

parser.rs (7495B)


      1 use std::collections::BTreeMap;
      2 
      3 use sha2::{Digest, Sha256};
      4 
      5 pub const SCHEMA: &str = "harvestcircle.product.v1";
      6 pub const REQUIRED_KEYS: &[&str] = &[
      7     "schema",
      8     "product.name",
      9     "product.slug",
     10     "kotlin.root_namespace",
     11     "desktop.application_id",
     12     "desktop.bundle_id",
     13     "desktop.main_class",
     14     "ffi.kotlin_package",
     15     "ffi.cdylib_name",
     16     "storage.service_id",
     17     "storage.instance_id",
     18     "storage.database_filename",
     19     "storage.lock_filename",
     20     "storage.application_id",
     21     "storage.application_id_text",
     22     "storage.initial_schema_version",
     23     "legacy.database.qualifier",
     24     "legacy.database.organization",
     25     "legacy.database.application",
     26     "legacy.database.filename",
     27     "legacy.database.disposition",
     28     "platform.macos.architecture",
     29     "platform.linux.architecture",
     30     "limit.identities",
     31     "limit.unfinished_durable_operations",
     32     "limit.preference_value_utf8_bytes",
     33     "limit.relay_endpoints",
     34     "limit.relay_url_bytes",
     35     "limit.events_per_relay",
     36     "limit.events_total",
     37     "limit.observers",
     38     "limit.actor_mailbox",
     39     "limit.command_deadline_min_ms",
     40     "limit.command_deadline_max_ms",
     41     "backup.member_limit",
     42     "keyring.service",
     43     "environment.prefix",
     44     "vendor.name",
     45     "copyright.notice",
     46 ];
     47 
     48 pub fn parse(source: &str) -> Result<BTreeMap<String, String>, String> {
     49     if source.starts_with('\u{feff}') {
     50         return Err("product coordinates must not contain a UTF-8 BOM".to_owned());
     51     }
     52     let mut parsed = BTreeMap::new();
     53     for (index, raw) in source.lines().enumerate() {
     54         let line = raw.trim();
     55         if line.is_empty() || line.starts_with('#') {
     56             continue;
     57         }
     58         let (key, value) = line
     59             .split_once('=')
     60             .ok_or_else(|| format!("line {} is not key=value", index + 1))?;
     61         let key = key.trim();
     62         let value = value.trim();
     63         if key.is_empty()
     64             || value.is_empty()
     65             || key.chars().any(char::is_control)
     66             || value.chars().any(char::is_control)
     67         {
     68             return Err(format!("line {} has an invalid key or value", index + 1));
     69         }
     70         if !REQUIRED_KEYS.contains(&key) {
     71             return Err(format!("unknown coordinate {key}"));
     72         }
     73         if parsed.insert(key.to_owned(), value.to_owned()).is_some() {
     74             return Err(format!("duplicate coordinate {key}"));
     75         }
     76     }
     77     for key in REQUIRED_KEYS {
     78         let value = parsed
     79             .get(*key)
     80             .ok_or_else(|| format!("missing coordinate {key}"))?;
     81         validate_coordinate(key, value)?;
     82     }
     83     Ok(parsed)
     84 }
     85 
     86 pub fn canonicalize(source: &str) -> Result<String, String> {
     87     let parsed = parse(source)?;
     88     let mut canonical = String::new();
     89     for key in REQUIRED_KEYS {
     90         canonical.push_str(key);
     91         canonical.push('=');
     92         canonical.push_str(parsed.get(*key).expect("required coordinate was validated"));
     93         canonical.push('\n');
     94     }
     95     Ok(canonical)
     96 }
     97 
     98 pub fn generate_rust_constants(source: &str) -> Result<String, String> {
     99     use std::fmt::Write as _;
    100 
    101     let coordinates = parse(source)?;
    102     let digest = digest(source)?;
    103     let mut generated = String::from("// @generated by harvestcircle_product/build.rs\n");
    104     for key in REQUIRED_KEYS {
    105         let constant = key.replace(['.', '-'], "_").to_ascii_uppercase();
    106         writeln!(
    107             generated,
    108             "pub const {constant}: &str = {:?};",
    109             coordinates.get(*key).expect("required coordinate")
    110         )
    111         .map_err(|error| error.to_string())?;
    112     }
    113     writeln!(
    114         generated,
    115         "pub const PRODUCT_COORDINATE_DIGEST: &str = {digest:?};"
    116     )
    117     .map_err(|error| error.to_string())?;
    118     writeln!(
    119         generated,
    120         "pub const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = {:?};",
    121         format!(
    122             "{}DEVELOPMENT_DATA_DIR",
    123             coordinates
    124                 .get("environment.prefix")
    125                 .expect("environment prefix")
    126         )
    127     )
    128     .map_err(|error| error.to_string())?;
    129     Ok(generated)
    130 }
    131 
    132 fn validate_coordinate(key: &str, value: &str) -> Result<(), String> {
    133     let valid = match key {
    134         "schema" => value == SCHEMA,
    135         "product.name" | "vendor.name" | "copyright.notice" => value.len() <= 160,
    136         "product.slug" | "ffi.cdylib_name" => is_lower_identifier(value),
    137         "kotlin.root_namespace"
    138         | "desktop.application_id"
    139         | "desktop.bundle_id"
    140         | "desktop.main_class"
    141         | "ffi.kotlin_package"
    142         | "keyring.service" => is_dotted_identifier(value),
    143         "storage.service_id" => value == "harvestcircle",
    144         "storage.instance_id" => value == "desktop",
    145         "storage.database_filename" => value == "state.sqlite",
    146         "storage.lock_filename" => value == "state.lock",
    147         "storage.application_id" => value == "1212371505",
    148         "storage.application_id_text" => value == "HCR1",
    149         "storage.initial_schema_version" => value == "1",
    150         "legacy.database.qualifier" => value == "org",
    151         "legacy.database.organization" => value == "harvestcircle",
    152         "legacy.database.application" => value == "desktop",
    153         "legacy.database.filename" => value == "harvestcircle.sqlite3",
    154         "legacy.database.disposition" => value == "untouched_and_unsupported",
    155         "platform.macos.architecture" => value == "aarch64",
    156         "platform.linux.architecture" => value == "x86_64",
    157         "limit.identities" => value == "256",
    158         "limit.unfinished_durable_operations" => value == "1024",
    159         "limit.preference_value_utf8_bytes" => value == "4096",
    160         "limit.relay_endpoints" => value == "16",
    161         "limit.relay_url_bytes" => value == "2048",
    162         "limit.events_per_relay" => value == "64",
    163         "limit.events_total" => value == "1024",
    164         "limit.observers" => value == "32",
    165         "limit.actor_mailbox" => value == "64",
    166         "limit.command_deadline_min_ms" => value == "1",
    167         "limit.command_deadline_max_ms" => value == "30000",
    168         "backup.member_limit" => value == "caller_supplied_positive",
    169         "environment.prefix" => {
    170             value
    171                 .bytes()
    172                 .next()
    173                 .is_some_and(|byte| byte.is_ascii_uppercase())
    174                 && value.ends_with('_')
    175                 && value
    176                     .bytes()
    177                     .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
    178         }
    179         _ => false,
    180     };
    181     if valid {
    182         Ok(())
    183     } else {
    184         Err(format!("coordinate {key} has an invalid value"))
    185     }
    186 }
    187 
    188 fn is_lower_identifier(value: &str) -> bool {
    189     value
    190         .bytes()
    191         .next()
    192         .is_some_and(|byte| byte.is_ascii_lowercase())
    193         && value
    194             .bytes()
    195             .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
    196 }
    197 
    198 fn is_dotted_identifier(value: &str) -> bool {
    199     value.split('.').all(|segment| {
    200         segment
    201             .bytes()
    202             .next()
    203             .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
    204             && segment
    205                 .bytes()
    206                 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
    207     })
    208 }
    209 
    210 pub fn digest(source: &str) -> Result<String, String> {
    211     let canonical = canonicalize(source)?;
    212     Ok(Sha256::digest(canonical.as_bytes())
    213         .iter()
    214         .map(|byte| format!("{byte:02x}"))
    215         .collect())
    216 }