1use std::fs;
35use std::path::{Path, PathBuf};
36
37use base64::Engine;
38use base64::engine::general_purpose::STANDARD as Base64Standard;
39use serde_json::{Value, from_str};
40use tempfile::TempDir;
41use tracing::debug;
42use zip::ZipArchive;
43
44use crate::error::{Error, Result};
45
46pub mod extensions;
52
53pub mod preferences;
55
56pub use extensions::ExtensionSource;
61pub use preferences::{FirefoxPreference, PreferenceValue};
62
63const USER_JS_HEADER: &str = "// Firefox WebDriver user.js\n\
69 // Auto-generated preferences for automation\n\n";
70
71pub struct Profile {
89 _temp_dir: Option<TempDir>,
91
92 path: PathBuf,
94}
95
96impl Profile {
101 pub fn new_temp() -> Result<Self> {
122 let temp_dir = TempDir::with_prefix("firefox-webdriver-")
123 .map_err(|e| Error::profile(format!("Failed to create temp profile: {}", e)))?;
124
125 let path = temp_dir.path().to_path_buf();
126 debug!(path = %path.display(), "Created temporary profile");
127
128 Ok(Self {
129 _temp_dir: Some(temp_dir),
130 path,
131 })
132 }
133
134 pub fn from_path(path: impl Into<PathBuf>) -> Result<Self> {
157 let path = path.into();
158
159 if !path.exists() {
160 fs::create_dir_all(&path).map_err(|e| {
161 Error::profile(format!(
162 "Failed to create profile directory at {}: {}",
163 path.display(),
164 e
165 ))
166 })?;
167 debug!(path = %path.display(), "Created profile directory");
168 } else {
169 debug!(path = %path.display(), "Using existing profile directory");
170 }
171
172 Ok(Self {
173 _temp_dir: None,
174 path,
175 })
176 }
177}
178
179impl Profile {
184 #[inline]
186 #[must_use]
187 pub fn path(&self) -> &Path {
188 &self.path
189 }
190
191 fn extensions_dir(&self) -> Result<PathBuf> {
197 let dir = self.path.join("extensions");
198 if !dir.exists() {
199 fs::create_dir_all(&dir).map_err(|e| {
200 Error::profile(format!(
201 "Failed to create extensions directory at {}: {}",
202 dir.display(),
203 e
204 ))
205 })?;
206 }
207 Ok(dir)
208 }
209}
210
211impl Profile {
216 pub fn write_prefs(&self, prefs: &[FirefoxPreference]) -> Result<()> {
226 let file_path = self.path.join("user.js");
227
228 let mut content = String::from(USER_JS_HEADER);
229 for pref in prefs {
230 content.push_str(&pref.to_user_pref_line());
231 content.push('\n');
232 }
233
234 fs::write(&file_path, content).map_err(|e| {
235 Error::profile(format!(
236 "Failed to write user.js at {}: {}",
237 file_path.display(),
238 e
239 ))
240 })?;
241
242 debug!(
243 path = %file_path.display(),
244 pref_count = prefs.len(),
245 "Wrote preferences to user.js"
246 );
247
248 Ok(())
249 }
250
251 #[must_use]
259 pub fn default_prefs() -> Vec<FirefoxPreference> {
260 use preferences::{FirefoxPreference as Pref, PreferenceValue as Val};
261
262 vec![
263 Pref::new("xpinstall.signatures.required", Val::Bool(false))
269 .with_comment("Only Firefox requires add-on signatures"),
270 Pref::new("extensions.autoDisableScopes", Val::Int(0))
272 .with_comment("Disable add-ons that are not installed by the user in all scopes"),
273 Pref::new(
275 "extensions.webextensions.restrictedDomains",
276 Val::String(String::new()),
277 )
278 .with_comment("Restricted domains for webextensions"),
279 Pref::new(
280 "security.data_uri.block_toplevel_data_uri_navigations",
281 Val::Bool(false),
282 )
283 .with_comment("Block toplevel data URI navigations"),
284 Pref::new("browser.startup.page", Val::Int(0))
290 .with_comment("0 = blank, 1 = home, 2 = last visited, 3 = resume session"),
291 Pref::new("browser.shell.checkDefaultBrowser", Val::Bool(false))
293 .with_comment("At startup, check if we're the default browser"),
294 Pref::new(
295 "browser.startup.homepage_override.mstone",
296 Val::String("ignore".into()),
297 )
298 .with_comment("Used to display upgrade page after version upgrade"),
299 Pref::new("browser.sessionstore.resume_from_crash", Val::Bool(false))
300 .with_comment("Whether to resume session after crash"),
301 Pref::new("toolkit.startup.max_resumed_crashes", Val::Int(-1))
303 .with_comment("Number of crashes before about:sessionrestore is displayed"),
304 Pref::new("browser.tabs.warnOnClose", Val::Bool(false))
305 .with_comment("Warn when closing multiple tabs"),
306 Pref::new("browser.tabs.warnOnCloseOtherTabs", Val::Bool(false))
307 .with_comment("Warn when closing other tabs"),
308 Pref::new("browser.warnOnQuit", Val::Bool(false))
310 .with_comment("Override all other possible prompts when quitting"),
311 Pref::new("browser.pagethumbnails.capturing_disabled", Val::Bool(true))
312 .with_comment("Disable page thumbnail capturing"),
313 Pref::new("browser.aboutConfig.showWarning", Val::Bool(false))
314 .with_comment("Show warning when accessing about:config"),
315 Pref::new(
316 "browser.bookmarks.restore_default_bookmarks",
317 Val::Bool(false),
318 )
319 .with_comment("Restore default bookmarks"),
320 Pref::new("browser.disableResetPrompt", Val::Bool(true))
321 .with_comment("Disable reset prompt"),
322 Pref::new("browser.download.panel.shown", Val::Bool(true))
324 .with_comment("Records whether download panel has been shown"),
325 Pref::new("browser.feeds.showFirstRunUI", Val::Bool(false))
326 .with_comment("Show first run UI for feeds"),
327 Pref::new(
328 "browser.messaging-system.whatsNewPanel.enabled",
329 Val::Bool(false),
330 )
331 .with_comment("Enable What's New panel"),
332 Pref::new("browser.rights.3.shown", Val::Bool(true))
333 .with_comment("Rights notification shown"),
334 Pref::new("browser.slowStartup.notificationDisabled", Val::Bool(true))
335 .with_comment("Disable slow startup notification"),
336 Pref::new("browser.slowStartup.maxSamples", Val::Int(0))
337 .with_comment("Max samples for slow startup detection"),
338 Pref::new("browser.uitour.enabled", Val::Bool(false))
340 .with_comment("UI tour experience"),
341 Pref::new("startup.homepage_welcome_url", Val::String(String::new()))
342 .with_comment("Welcome page URL"),
343 Pref::new(
344 "startup.homepage_welcome_url.additional",
345 Val::String(String::new()),
346 )
347 .with_comment("Additional welcome page URL"),
348 Pref::new("startup.homepage_override_url", Val::String(String::new()))
349 .with_comment("Homepage override URL"),
350 Pref::new("toolkit.telemetry.unified", Val::Bool(false))
356 .with_comment("Whether to use unified telemetry behavior"),
357 Pref::new("toolkit.telemetry.enabled", Val::Bool(false))
358 .with_comment("Enable telemetry"),
359 Pref::new("toolkit.telemetry.server", Val::String(String::new()))
361 .with_comment("Server to submit telemetry pings to"),
362 Pref::new("toolkit.telemetry.archive.enabled", Val::Bool(false))
363 .with_comment("Enable telemetry archive"),
364 Pref::new("toolkit.telemetry.newProfilePing.enabled", Val::Bool(false))
365 .with_comment("Enable new profile ping"),
366 Pref::new(
367 "toolkit.telemetry.shutdownPingSender.enabled",
368 Val::Bool(false),
369 )
370 .with_comment("Enable shutdown ping sender"),
371 Pref::new("toolkit.telemetry.updatePing.enabled", Val::Bool(false))
372 .with_comment("Enable update ping"),
373 Pref::new("toolkit.telemetry.bhrPing.enabled", Val::Bool(false))
374 .with_comment("Enable BHR (Background Hang Reporter) ping"),
375 Pref::new(
376 "toolkit.telemetry.firstShutdownPing.enabled",
377 Val::Bool(false),
378 )
379 .with_comment("Enable first shutdown ping"),
380 Pref::new(
381 "toolkit.telemetry.reportingpolicy.firstRun",
382 Val::Bool(false),
383 )
384 .with_comment("First run reporting policy"),
385 Pref::new(
386 "datareporting.policy.dataSubmissionEnabled",
387 Val::Bool(false),
388 )
389 .with_comment("Enable data submission"),
390 Pref::new("datareporting.healthreport.uploadEnabled", Val::Bool(false))
391 .with_comment("Enable health report upload"),
392 Pref::new(
393 "browser.newtabpage.activity-stream.feeds.telemetry",
394 Val::Bool(false),
395 )
396 .with_comment("Activity stream feeds telemetry"),
397 Pref::new(
398 "browser.newtabpage.activity-stream.telemetry",
399 Val::Bool(false),
400 )
401 .with_comment("Activity stream telemetry"),
402 Pref::new("browser.ping-centre.telemetry", Val::Bool(false))
403 .with_comment("Ping centre telemetry"),
404 Pref::new("app.update.staging.enabled", Val::Bool(false))
410 .with_comment("Apply updates in the background when finished downloading"),
411 Pref::new("app.update.service.enabled", Val::Bool(false))
413 .with_comment("Whether to attempt using the service for updates"),
414 Pref::new("extensions.update.enabled", Val::Bool(false))
415 .with_comment("Check for updates to Extensions and Themes"),
416 Pref::new("extensions.getAddons.cache.enabled", Val::Bool(false))
417 .with_comment("Enable add-ons cache"),
418 Pref::new("browser.search.update", Val::Bool(false))
419 .with_comment("Enable search engine updates"),
420 Pref::new("app.normandy.enabled", Val::Bool(false))
424 .with_comment("Enable Normandy/Shield studies"),
425 Pref::new("app.normandy.api_url", Val::String(String::new()))
426 .with_comment("Normandy API URL"),
427 Pref::new("browser.safebrowsing.malware.enabled", Val::Bool(false))
428 .with_comment("Enable Safe Browsing malware checks"),
429 Pref::new("browser.safebrowsing.phishing.enabled", Val::Bool(false))
430 .with_comment("Enable Safe Browsing phishing checks"),
431 Pref::new("browser.safebrowsing.downloads.enabled", Val::Bool(false))
432 .with_comment("Enable Safe Browsing download checks"),
433 Pref::new("browser.safebrowsing.blockedURIs.enabled", Val::Bool(false))
434 .with_comment("Enable Safe Browsing blocked URIs"),
435 Pref::new("network.captive-portal-service.enabled", Val::Bool(false))
437 .with_comment("Enable captive portal detection"),
438 Pref::new("network.connectivity-service.enabled", Val::Bool(false))
439 .with_comment("Enable connectivity service"),
440 Pref::new("network.dns.disableIPv6", Val::Bool(true))
445 .with_comment("Disable IPv6 DNS lookups"),
446 Pref::new("network.proxy.socks_remote_dns", Val::Bool(true))
447 .with_comment("Force DNS through SOCKS proxy"),
448 Pref::new("network.trr.mode", Val::Int(2))
450 .with_comment("TRR mode: 0=off, 2=TRR first, 3=TRR only"),
451 Pref::new(
452 "network.trr.uri",
453 Val::String("https://cloudflare-dns.com/dns-query".into()),
454 )
455 .with_comment("DNS-over-HTTPS server URI"),
456 Pref::new("network.trr.bootstrapAddr", Val::String("1.1.1.1".into()))
457 .with_comment("Bootstrap address for TRR"),
458 Pref::new("network.dns.echconfig.enabled", Val::Bool(false))
459 .with_comment("Enable ECH (Encrypted Client Hello)"),
460 Pref::new("network.dns.http3_echconfig.enabled", Val::Bool(false))
461 .with_comment("Enable HTTP/3 ECH"),
462 Pref::new("security.OCSP.enabled", Val::Int(0))
463 .with_comment("OCSP: 0=disabled, 1=enabled, 2=enabled for EV only"),
464 Pref::new("security.ssl.enable_ocsp_stapling", Val::Bool(false))
465 .with_comment("Enable OCSP stapling"),
466 Pref::new("security.ssl.enable_ocsp_must_staple", Val::Bool(false))
467 .with_comment("Enable OCSP must-staple"),
468 Pref::new("network.dns.disablePrefetch", Val::Bool(true))
473 .with_comment("Disable DNS prefetching"),
474 Pref::new("network.dns.disablePrefetchFromHTTPS", Val::Bool(true))
475 .with_comment("Disable DNS prefetch from HTTPS pages"),
476 Pref::new("network.prefetch-next", Val::Bool(false))
478 .with_comment("Enable prefetch service for link rel=next/prefetch"),
479 Pref::new("network.http.speculative-parallel-limit", Val::Int(0))
481 .with_comment("Max global half open sockets for speculative connections"),
482 Pref::new("network.predictor.enabled", Val::Bool(false))
483 .with_comment("Enable network predictor"),
484 Pref::new("network.predictor.enable-prefetch", Val::Bool(false))
485 .with_comment("Enable network predictor prefetch"),
486 Pref::new(
488 "browser.urlbar.speculativeConnect.enabled",
489 Val::Bool(false),
490 )
491 .with_comment("Warm up network connections for autofill/search results"),
492 Pref::new(
494 "browser.places.speculativeConnect.enabled",
495 Val::Bool(false),
496 )
497 .with_comment("Warm up network connections for places"),
498 Pref::new("browser.urlbar.suggest.searches", Val::Bool(false))
499 .with_comment("Suggest searches in URL bar"),
500 Pref::new("media.peerconnection.enabled", Val::Bool(false))
505 .with_comment("Enable WebRTC peer connections"),
506 Pref::new(
507 "media.peerconnection.ice.default_address_only",
508 Val::Bool(true),
509 )
510 .with_comment("Only use default address for ICE candidates"),
511 Pref::new("media.peerconnection.ice.no_host", Val::Bool(true))
512 .with_comment("Don't include host candidates in ICE"),
513 Pref::new(
514 "media.peerconnection.ice.proxy_only_if_behind_proxy",
515 Val::Bool(true),
516 )
517 .with_comment("Only use proxy for ICE if behind proxy"),
518 Pref::new("dom.push.enabled", Val::Bool(false)).with_comment("Enable Push API"),
523 Pref::new("dom.push.connection.enabled", Val::Bool(false))
525 .with_comment("Enable Push API network connection"),
526 Pref::new("beacon.enabled", Val::Bool(false)).with_comment("Enable Beacon API"),
527 Pref::new("privacy.resistFingerprinting", Val::Bool(false))
532 .with_comment("Disable fingerprinting resistance (intentional - RFP artifacts are detectable)"),
533 Pref::new("dom.ipc.processCount", Val::Int(1))
539 .with_comment("Number of content processes"),
540 Pref::new("browser.tabs.remote.autostart", Val::Bool(true))
541 .with_comment("Enable multi-process tabs"),
542 ]
543 }
544}
545
546impl Profile {
551 pub fn install_extension(&self, source: &ExtensionSource) -> Result<()> {
561 match source {
562 ExtensionSource::Unpacked(path) => {
563 debug!(path = %path.display(), "Installing unpacked extension");
564 self.install_unpacked(path)
565 }
566 ExtensionSource::Packed(path) => {
567 debug!(path = %path.display(), "Installing packed extension");
568 self.install_packed(path)
569 }
570 ExtensionSource::Base64(data) => {
571 debug!("Installing base64 extension");
572 self.install_base64(data)
573 }
574 }
575 }
576
577 fn install_unpacked(&self, src: &Path) -> Result<()> {
579 let extension_id = self.read_manifest_id(src)?;
580 let dest = self.extensions_dir()?.join(&extension_id);
581
582 copy_dir_recursive(src, &dest)?;
583
584 debug!(
585 extension_id = %extension_id,
586 dest = %dest.display(),
587 "Installed unpacked extension"
588 );
589
590 Ok(())
591 }
592
593 fn install_packed(&self, src: &Path) -> Result<()> {
595 let file = fs::File::open(src).map_err(Error::Io)?;
596 let mut archive = ZipArchive::new(file)
597 .map_err(|e| Error::profile(format!("Invalid extension archive: {}", e)))?;
598
599 let temp_extract = TempDir::new().map_err(Error::Io)?;
600 archive
601 .extract(temp_extract.path())
602 .map_err(|e| Error::profile(format!("Failed to extract extension: {}", e)))?;
603
604 self.install_unpacked(temp_extract.path())
605 }
606
607 fn install_base64(&self, data: &str) -> Result<()> {
609 let bytes = Base64Standard
611 .decode(data)
612 .map_err(|e| Error::profile(format!("Invalid base64 extension data: {}", e)))?;
613
614 let temp_dir = TempDir::new().map_err(Error::Io)?;
616 let temp_xpi = temp_dir.path().join("extension.xpi");
617 fs::write(&temp_xpi, bytes).map_err(Error::Io)?;
618
619 self.install_packed(&temp_xpi)
621 }
622
623 fn read_manifest_id(&self, dir: &Path) -> Result<String> {
625 let manifest_path = dir.join("manifest.json");
626 let content = fs::read_to_string(&manifest_path).map_err(|e| {
627 Error::profile(format!(
628 "Extension manifest not found at {}: {}",
629 manifest_path.display(),
630 e
631 ))
632 })?;
633
634 let json: Value = from_str(&content)
635 .map_err(|e| Error::profile(format!("Invalid manifest.json: {}", e)))?;
636
637 if let Some(id) = json.pointer("/browser_specific_settings/gecko/id")
639 && let Some(id_str) = id.as_str()
640 {
641 return Ok(id_str.to_string());
642 }
643
644 if let Some(id) = json.pointer("/applications/gecko/id")
645 && let Some(id_str) = id.as_str()
646 {
647 return Ok(id_str.to_string());
648 }
649
650 Err(Error::profile(
651 "Extension manifest missing 'gecko.id' field".to_string(),
652 ))
653 }
654}
655
656fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
664 fs::create_dir_all(dst).map_err(Error::Io)?;
665
666 for entry in fs::read_dir(src).map_err(Error::Io)? {
667 let entry = entry.map_err(Error::Io)?;
668 let file_type = entry.file_type().map_err(Error::Io)?;
669 let src_path = entry.path();
670 let dst_path = dst.join(entry.file_name());
671
672 if file_type.is_symlink() {
674 continue;
675 }
676
677 if file_type.is_dir() {
678 copy_dir_recursive(&src_path, &dst_path)?;
679 } else {
680 fs::copy(&src_path, &dst_path).map_err(Error::Io)?;
681 }
682 }
683
684 Ok(())
685}
686
687#[cfg(test)]
692mod tests {
693 use super::Profile;
694
695 #[test]
696 fn test_new_temp_creates_directory() {
697 let profile = Profile::new_temp().expect("create temp profile");
698 assert!(profile.path().exists());
699 assert!(profile.path().is_dir());
700 }
701
702 #[test]
703 fn test_temp_profile_cleanup_on_drop() {
704 let path = {
705 let profile = Profile::new_temp().expect("create temp profile");
706 let path = profile.path().to_path_buf();
707 assert!(path.exists());
708 path
709 };
710 assert!(!path.exists());
711 }
712
713 #[test]
714 fn test_default_prefs_not_empty() {
715 let prefs = Profile::default_prefs();
716 assert!(!prefs.is_empty());
717 }
718
719 #[test]
720 fn test_from_path_creates_directory() {
721 let temp = tempfile::tempdir().expect("create temp dir");
722 let profile_path = temp.path().join("test_profile");
723
724 assert!(!profile_path.exists());
725 let profile = Profile::from_path(&profile_path).expect("create profile");
726 assert!(profile.path().exists());
727 }
728}