|
| 1 | +/* This Source Code Form is subject to the terms of the Mozilla Public |
| 2 | + * License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
| 4 | + |
| 5 | +//! Shared logic for obtaining the enterprise console address. |
| 6 | +//! |
| 7 | +//! A repack bakes the console address into the AutoConfig file |
| 8 | +//! (`firefox.cfg`); on generic (non-repacked) builds the file holds |
| 9 | +//! [`CONSOLE_ADDRESS_PLACEHOLDER`] instead, and the address is resolved from |
| 10 | +//! the [`CONSOLE_ADDRESS_ENV`] environment variable or from the value the |
| 11 | +//! console setup dialog persisted in `felt.json`. This crate keeps that |
| 12 | +//! resolution identical for every native consumer: the browser (through the |
| 13 | +//! felt crate's FFI, used by CreateAppData.cpp) and the standalone crash |
| 14 | +//! reporter client. `resolveConsoleAddress` in ConsoleClient.sys.mjs mirrors |
| 15 | +//! it in JS. |
| 16 | +//! |
| 17 | +//! The crate is IO-free: callers hand in file contents and the environment |
| 18 | +//! value, so each consumer keeps its own file and environment access (the |
| 19 | +//! crash reporter mocks both in its tests). |
| 20 | +
|
| 21 | +/// The pref holding the enterprise console address, set by the AutoConfig |
| 22 | +/// file. |
| 23 | +pub const CONSOLE_ADDRESS_PREF: &str = "enterprise.console.address"; |
| 24 | + |
| 25 | +/// Placeholder address in the AutoConfig file of generic (non-repacked) |
| 26 | +/// builds. Keep in sync with CONSOLE_ADDRESS_PLACEHOLDER in |
| 27 | +/// ConsoleClient.sys.mjs and the placeholder in |
| 28 | +/// browser/branding/enterprise/byteshift.py. |
| 29 | +pub const CONSOLE_ADDRESS_PLACEHOLDER: &str = "FIREFOX_ENTERPRISE_GENERIC"; |
| 30 | + |
| 31 | +/// Environment variable providing the console address on generic builds |
| 32 | +/// (used by test harnesses). |
| 33 | +pub const CONSOLE_ADDRESS_ENV: &str = "MOZ_ENTERPRISE_CONSOLE_ADDRESS"; |
| 34 | + |
| 35 | +/// Storage file in the user application data directory where the console |
| 36 | +/// setup dialog persists the address on generic builds. |
| 37 | +pub const FELT_STORAGE_FILENAME: &str = "felt.json"; |
| 38 | + |
| 39 | +/// Key holding the console address in the felt storage file. Keep in sync |
| 40 | +/// with FeltStorage.sys.mjs. |
| 41 | +pub const FELT_CONSOLE_ADDRESS_KEY: &str = "consoleAddress"; |
| 42 | + |
| 43 | +/// Default byte shift applied to AutoConfig files |
| 44 | +/// (`general.config.obscure_value`). Keep in sync with OBSCURE_VALUE in |
| 45 | +/// browser/branding/enterprise/byteshift.py. |
| 46 | +pub const DEFAULT_OBSCURE_VALUE: u8 = 13; |
| 47 | + |
| 48 | +/// Pref-setting functions an AutoConfig file may use to set a string pref. |
| 49 | +const PREF_FUNCTIONS: &[&str] = &["lockPref", "defaultPref", "pref"]; |
| 50 | + |
| 51 | +/// Extract the console address from the raw contents of an AutoConfig file |
| 52 | +/// without evaluating it. |
| 53 | +/// |
| 54 | +/// AutoConfig files are byte shifted by `general.config.obscure_value` and |
| 55 | +/// have an intentionally unparseable first line; the shift is undone (trying |
| 56 | +/// the default value, then plaintext) and the file scanned for the pref call. |
| 57 | +/// On generic builds this yields [`CONSOLE_ADDRESS_PLACEHOLDER`]; pass the |
| 58 | +/// result through [`resolve_console_address`]. |
| 59 | +pub fn console_address_from_autoconfig(contents: &[u8]) -> Option<String> { |
| 60 | + for shift in [DEFAULT_OBSCURE_VALUE, 0] { |
| 61 | + let decoded: Vec<u8> = contents.iter().map(|b| b.wrapping_sub(shift)).collect(); |
| 62 | + let Ok(text) = String::from_utf8(decoded) else { |
| 63 | + continue; |
| 64 | + }; |
| 65 | + for func in PREF_FUNCTIONS { |
| 66 | + if let Some(value) = find_string_pref_call(&text, func, CONSOLE_ADDRESS_PREF) { |
| 67 | + return Some(value.to_owned()); |
| 68 | + } |
| 69 | + } |
| 70 | + } |
| 71 | + None |
| 72 | +} |
| 73 | + |
| 74 | +/// Resolve a console address that may be [`CONSOLE_ADDRESS_PLACEHOLDER`]. |
| 75 | +/// |
| 76 | +/// A real address is returned unchanged. The placeholder resolves to |
| 77 | +/// `env_value` (the value of [`CONSOLE_ADDRESS_ENV`]) when non-empty, then to |
| 78 | +/// the address stored in felt.json, whose contents `read_felt_json` supplies |
| 79 | +/// (called only when needed). Returns None when the placeholder cannot be |
| 80 | +/// resolved, in which case the browser shows the console setup dialog. |
| 81 | +pub fn resolve_console_address( |
| 82 | + address: &str, |
| 83 | + env_value: Option<&str>, |
| 84 | + read_felt_json: impl FnOnce() -> Option<Vec<u8>>, |
| 85 | +) -> Option<String> { |
| 86 | + if address != CONSOLE_ADDRESS_PLACEHOLDER { |
| 87 | + return Some(address.to_owned()); |
| 88 | + } |
| 89 | + if let Some(url) = env_value { |
| 90 | + if !url.is_empty() { |
| 91 | + return Some(url.to_owned()); |
| 92 | + } |
| 93 | + } |
| 94 | + stored_console_address(&read_felt_json()?) |
| 95 | +} |
| 96 | + |
| 97 | +/// Read the console address out of felt.json contents. |
| 98 | +pub fn stored_console_address(felt_json: &[u8]) -> Option<String> { |
| 99 | + let json: serde_json::Value = serde_json::from_slice(felt_json).ok()?; |
| 100 | + match json.get(FELT_CONSOLE_ADDRESS_KEY).and_then(|v| v.as_str()) { |
| 101 | + Some(url) if !url.is_empty() => Some(url.to_owned()), |
| 102 | + _ => None, |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +/// Outcome of [`remove_stored_console_address`]. |
| 107 | +pub enum RemoveStoredAddress { |
| 108 | + /// No address was stored (including unparseable contents); nothing to |
| 109 | + /// write back. |
| 110 | + AlreadyAbsent, |
| 111 | + /// The address was removed; the new felt.json contents to write back. |
| 112 | + Removed(String), |
| 113 | + /// The contents are not a JSON object; nothing can be removed. |
| 114 | + Invalid, |
| 115 | +} |
| 116 | + |
| 117 | +/// Remove the console address from felt.json contents, keeping other keys. |
| 118 | +pub fn remove_stored_console_address(felt_json: &[u8]) -> RemoveStoredAddress { |
| 119 | + let Ok(mut json) = serde_json::from_slice::<serde_json::Value>(felt_json) else { |
| 120 | + return RemoveStoredAddress::AlreadyAbsent; |
| 121 | + }; |
| 122 | + let Some(obj) = json.as_object_mut() else { |
| 123 | + return RemoveStoredAddress::Invalid; |
| 124 | + }; |
| 125 | + if obj.remove(FELT_CONSOLE_ADDRESS_KEY).is_none() { |
| 126 | + return RemoveStoredAddress::AlreadyAbsent; |
| 127 | + } |
| 128 | + RemoveStoredAddress::Removed(json.to_string()) |
| 129 | +} |
| 130 | + |
| 131 | +/// Find the string value of a `func("pref", "value");` call, ignoring calls |
| 132 | +/// setting other prefs and occurrences of the pref name in other positions. |
| 133 | +fn find_string_pref_call<'a>(contents: &'a str, func: &str, pref: &str) -> Option<&'a str> { |
| 134 | + let opener = format!("{func}("); |
| 135 | + let mut search_content = contents; |
| 136 | + loop { |
| 137 | + let (before, s) = search_content.split_once(&format!("\"{pref}\""))?; |
| 138 | + if !before.trim().ends_with(&opener) { |
| 139 | + search_content = s; |
| 140 | + continue; |
| 141 | + } |
| 142 | + let s = s.trim_start_matches(|c: char| c.is_whitespace() || c == ','); |
| 143 | + let (content, _) = s.split_once(");")?; |
| 144 | + return content.trim().strip_prefix('"')?.strip_suffix('"'); |
| 145 | + } |
| 146 | +} |
| 147 | + |
| 148 | +#[cfg(test)] |
| 149 | +mod test { |
| 150 | + use super::*; |
| 151 | + |
| 152 | + fn encode(plaintext: &str) -> Vec<u8> { |
| 153 | + plaintext |
| 154 | + .bytes() |
| 155 | + .map(|b| b.wrapping_add(DEFAULT_OBSCURE_VALUE)) |
| 156 | + .collect() |
| 157 | + } |
| 158 | + |
| 159 | + const CFG: &str = "// first line is ignored\n\ |
| 160 | + lockPref(\"enterprise.console.address\", \"https://console.example.com/foo/\");"; |
| 161 | + |
| 162 | + #[test] |
| 163 | + fn autoconfig_encoded() { |
| 164 | + assert_eq!( |
| 165 | + console_address_from_autoconfig(&encode(CFG)).as_deref(), |
| 166 | + Some("https://console.example.com/foo/") |
| 167 | + ); |
| 168 | + } |
| 169 | + |
| 170 | + #[test] |
| 171 | + fn autoconfig_plaintext() { |
| 172 | + assert_eq!( |
| 173 | + console_address_from_autoconfig(CFG.as_bytes()).as_deref(), |
| 174 | + Some("https://console.example.com/foo/") |
| 175 | + ); |
| 176 | + } |
| 177 | + |
| 178 | + #[test] |
| 179 | + fn autoconfig_default_pref_function() { |
| 180 | + let cfg = r#"defaultPref("enterprise.console.address", "https://d.example.com/");"#; |
| 181 | + assert_eq!( |
| 182 | + console_address_from_autoconfig(cfg.as_bytes()).as_deref(), |
| 183 | + Some("https://d.example.com/") |
| 184 | + ); |
| 185 | + } |
| 186 | + |
| 187 | + #[test] |
| 188 | + fn autoconfig_ignores_other_prefs_and_positions() { |
| 189 | + let cfg = "lockPref(\"other.pref\", \"enterprise.console.address\");\n\ |
| 190 | + lockPref(\"enterprise.console.address\", \"https://real.example.com/\");"; |
| 191 | + assert_eq!( |
| 192 | + console_address_from_autoconfig(cfg.as_bytes()).as_deref(), |
| 193 | + Some("https://real.example.com/") |
| 194 | + ); |
| 195 | + } |
| 196 | + |
| 197 | + #[test] |
| 198 | + fn autoconfig_missing_pref() { |
| 199 | + assert_eq!(console_address_from_autoconfig(b"// nothing here"), None); |
| 200 | + } |
| 201 | + |
| 202 | + #[test] |
| 203 | + fn resolve_real_address_passes_through() { |
| 204 | + assert_eq!( |
| 205 | + resolve_console_address("https://console.example.com/", None, || panic!( |
| 206 | + "must not read felt.json" |
| 207 | + )) |
| 208 | + .as_deref(), |
| 209 | + Some("https://console.example.com/") |
| 210 | + ); |
| 211 | + } |
| 212 | + |
| 213 | + #[test] |
| 214 | + fn resolve_placeholder_from_environment() { |
| 215 | + assert_eq!( |
| 216 | + resolve_console_address( |
| 217 | + CONSOLE_ADDRESS_PLACEHOLDER, |
| 218 | + Some("https://env.example.com/"), |
| 219 | + || panic!("must not read felt.json") |
| 220 | + ) |
| 221 | + .as_deref(), |
| 222 | + Some("https://env.example.com/") |
| 223 | + ); |
| 224 | + } |
| 225 | + |
| 226 | + #[test] |
| 227 | + fn resolve_placeholder_from_felt_storage() { |
| 228 | + assert_eq!( |
| 229 | + resolve_console_address(CONSOLE_ADDRESS_PLACEHOLDER, Some(""), || Some( |
| 230 | + br#"{"consoleAddress": "https://stored.example.com/"}"#.to_vec() |
| 231 | + )) |
| 232 | + .as_deref(), |
| 233 | + Some("https://stored.example.com/") |
| 234 | + ); |
| 235 | + } |
| 236 | + |
| 237 | + #[test] |
| 238 | + fn resolve_placeholder_unresolvable() { |
| 239 | + assert_eq!( |
| 240 | + resolve_console_address(CONSOLE_ADDRESS_PLACEHOLDER, None, || Some( |
| 241 | + br#"{"deviceId": "abc"}"#.to_vec() |
| 242 | + )), |
| 243 | + None |
| 244 | + ); |
| 245 | + assert_eq!( |
| 246 | + resolve_console_address(CONSOLE_ADDRESS_PLACEHOLDER, None, || None), |
| 247 | + None |
| 248 | + ); |
| 249 | + } |
| 250 | + |
| 251 | + #[test] |
| 252 | + fn remove_stored_address_keeps_other_keys() { |
| 253 | + let json = br#"{"consoleAddress": "https://x.example.com/", "deviceId": "abc"}"#; |
| 254 | + match remove_stored_console_address(json) { |
| 255 | + RemoveStoredAddress::Removed(new_json) => { |
| 256 | + assert_eq!(new_json, r#"{"deviceId":"abc"}"#); |
| 257 | + } |
| 258 | + _ => panic!("expected Removed"), |
| 259 | + } |
| 260 | + } |
| 261 | + |
| 262 | + #[test] |
| 263 | + fn remove_stored_address_absent() { |
| 264 | + assert!(matches!( |
| 265 | + remove_stored_console_address(br#"{"deviceId": "abc"}"#), |
| 266 | + RemoveStoredAddress::AlreadyAbsent |
| 267 | + )); |
| 268 | + assert!(matches!( |
| 269 | + remove_stored_console_address(b"not json"), |
| 270 | + RemoveStoredAddress::AlreadyAbsent |
| 271 | + )); |
| 272 | + } |
| 273 | + |
| 274 | + #[test] |
| 275 | + fn remove_stored_address_invalid() { |
| 276 | + assert!(matches!( |
| 277 | + remove_stored_console_address(b"[1, 2]"), |
| 278 | + RemoveStoredAddress::Invalid |
| 279 | + )); |
| 280 | + } |
| 281 | +} |
0 commit comments