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 }