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) -> PathBuf {
193 let dir = self.path.join("extensions");
194 if !dir.exists() {
195 let _ = fs::create_dir_all(&dir);
196 }
197 dir
198 }
199}
200
201impl Profile {
206 pub fn write_prefs(&self, prefs: &[FirefoxPreference]) -> Result<()> {
216 let file_path = self.path.join("user.js");
217
218 let mut content = String::from(USER_JS_HEADER);
219 for pref in prefs {
220 content.push_str(&pref.to_user_pref_line());
221 content.push('\n');
222 }
223
224 fs::write(&file_path, content).map_err(|e| {
225 Error::profile(format!(
226 "Failed to write user.js at {}: {}",
227 file_path.display(),
228 e
229 ))
230 })?;
231
232 debug!(
233 path = %file_path.display(),
234 pref_count = prefs.len(),
235 "Wrote preferences to user.js"
236 );
237
238 Ok(())
239 }
240
241 #[must_use]
249 pub fn default_prefs() -> Vec<FirefoxPreference> {
250 use preferences::{FirefoxPreference as Pref, PreferenceValue as Val};
251
252 vec![
253 Pref::new("xpinstall.signatures.required", Val::Bool(false))
259 .with_comment("Only Firefox requires add-on signatures"),
260 Pref::new("extensions.autoDisableScopes", Val::Int(0))
262 .with_comment("Disable add-ons that are not installed by the user in all scopes"),
263 Pref::new(
265 "extensions.webextensions.restrictedDomains",
266 Val::String(String::new()),
267 )
268 .with_comment("Restricted domains for webextensions"),
269 Pref::new(
270 "security.data_uri.block_toplevel_data_uri_navigations",
271 Val::Bool(false),
272 )
273 .with_comment("Block toplevel data URI navigations"),
274 Pref::new("browser.startup.page", Val::Int(0))
280 .with_comment("0 = blank, 1 = home, 2 = last visited, 3 = resume session"),
281 Pref::new("browser.shell.checkDefaultBrowser", Val::Bool(false))
283 .with_comment("At startup, check if we're the default browser"),
284 Pref::new(
285 "browser.startup.homepage_override.mstone",
286 Val::String("ignore".into()),
287 )
288 .with_comment("Used to display upgrade page after version upgrade"),
289 Pref::new("browser.sessionstore.resume_from_crash", Val::Bool(false))
290 .with_comment("Whether to resume session after crash"),
291 Pref::new("toolkit.startup.max_resumed_crashes", Val::Int(-1))
293 .with_comment("Number of crashes before about:sessionrestore is displayed"),
294 Pref::new("browser.tabs.warnOnClose", Val::Bool(false))
295 .with_comment("Warn when closing multiple tabs"),
296 Pref::new("browser.tabs.warnOnCloseOtherTabs", Val::Bool(false))
297 .with_comment("Warn when closing other tabs"),
298 Pref::new("browser.warnOnQuit", Val::Bool(false))
300 .with_comment("Override all other possible prompts when quitting"),
301 Pref::new("browser.pagethumbnails.capturing_disabled", Val::Bool(true))
302 .with_comment("Disable page thumbnail capturing"),
303 Pref::new("browser.aboutConfig.showWarning", Val::Bool(false))
304 .with_comment("Show warning when accessing about:config"),
305 Pref::new(
306 "browser.bookmarks.restore_default_bookmarks",
307 Val::Bool(false),
308 )
309 .with_comment("Restore default bookmarks"),
310 Pref::new("browser.disableResetPrompt", Val::Bool(true))
311 .with_comment("Disable reset prompt"),
312 Pref::new("browser.download.panel.shown", Val::Bool(true))
314 .with_comment("Records whether download panel has been shown"),
315 Pref::new("browser.feeds.showFirstRunUI", Val::Bool(false))
316 .with_comment("Show first run UI for feeds"),
317 Pref::new(
318 "browser.messaging-system.whatsNewPanel.enabled",
319 Val::Bool(false),
320 )
321 .with_comment("Enable What's New panel"),
322 Pref::new("browser.rights.3.shown", Val::Bool(true))
323 .with_comment("Rights notification shown"),
324 Pref::new("browser.slowStartup.notificationDisabled", Val::Bool(true))
325 .with_comment("Disable slow startup notification"),
326 Pref::new("browser.slowStartup.maxSamples", Val::Int(0))
327 .with_comment("Max samples for slow startup detection"),
328 Pref::new("browser.uitour.enabled", Val::Bool(false))
330 .with_comment("UI tour experience"),
331 Pref::new("startup.homepage_welcome_url", Val::String(String::new()))
332 .with_comment("Welcome page URL"),
333 Pref::new(
334 "startup.homepage_welcome_url.additional",
335 Val::String(String::new()),
336 )
337 .with_comment("Additional welcome page URL"),
338 Pref::new("startup.homepage_override_url", Val::String(String::new()))
339 .with_comment("Homepage override URL"),
340 Pref::new("toolkit.telemetry.unified", Val::Bool(false))
346 .with_comment("Whether to use unified telemetry behavior"),
347 Pref::new("toolkit.telemetry.enabled", Val::Bool(false))
348 .with_comment("Enable telemetry"),
349 Pref::new("toolkit.telemetry.server", Val::String(String::new()))
351 .with_comment("Server to submit telemetry pings to"),
352 Pref::new("toolkit.telemetry.archive.enabled", Val::Bool(false))
353 .with_comment("Enable telemetry archive"),
354 Pref::new("toolkit.telemetry.newProfilePing.enabled", Val::Bool(false))
355 .with_comment("Enable new profile ping"),
356 Pref::new(
357 "toolkit.telemetry.shutdownPingSender.enabled",
358 Val::Bool(false),
359 )
360 .with_comment("Enable shutdown ping sender"),
361 Pref::new("toolkit.telemetry.updatePing.enabled", Val::Bool(false))
362 .with_comment("Enable update ping"),
363 Pref::new("toolkit.telemetry.bhrPing.enabled", Val::Bool(false))
364 .with_comment("Enable BHR (Background Hang Reporter) ping"),
365 Pref::new(
366 "toolkit.telemetry.firstShutdownPing.enabled",
367 Val::Bool(false),
368 )
369 .with_comment("Enable first shutdown ping"),
370 Pref::new(
371 "toolkit.telemetry.reportingpolicy.firstRun",
372 Val::Bool(false),
373 )
374 .with_comment("First run reporting policy"),
375 Pref::new(
376 "datareporting.policy.dataSubmissionEnabled",
377 Val::Bool(false),
378 )
379 .with_comment("Enable data submission"),
380 Pref::new("datareporting.healthreport.uploadEnabled", Val::Bool(false))
381 .with_comment("Enable health report upload"),
382 Pref::new(
383 "browser.newtabpage.activity-stream.feeds.telemetry",
384 Val::Bool(false),
385 )
386 .with_comment("Activity stream feeds telemetry"),
387 Pref::new(
388 "browser.newtabpage.activity-stream.telemetry",
389 Val::Bool(false),
390 )
391 .with_comment("Activity stream telemetry"),
392 Pref::new("browser.ping-centre.telemetry", Val::Bool(false))
393 .with_comment("Ping centre telemetry"),
394 Pref::new("app.update.staging.enabled", Val::Bool(false))
400 .with_comment("Apply updates in the background when finished downloading"),
401 Pref::new("app.update.service.enabled", Val::Bool(false))
403 .with_comment("Whether to attempt using the service for updates"),
404 Pref::new("extensions.update.enabled", Val::Bool(false))
405 .with_comment("Check for updates to Extensions and Themes"),
406 Pref::new("extensions.getAddons.cache.enabled", Val::Bool(false))
407 .with_comment("Enable add-ons cache"),
408 Pref::new("browser.search.update", Val::Bool(false))
409 .with_comment("Enable search engine updates"),
410 Pref::new("app.normandy.enabled", Val::Bool(false))
414 .with_comment("Enable Normandy/Shield studies"),
415 Pref::new("app.normandy.api_url", Val::String(String::new()))
416 .with_comment("Normandy API URL"),
417 Pref::new("browser.safebrowsing.malware.enabled", Val::Bool(false))
418 .with_comment("Enable Safe Browsing malware checks"),
419 Pref::new("browser.safebrowsing.phishing.enabled", Val::Bool(false))
420 .with_comment("Enable Safe Browsing phishing checks"),
421 Pref::new("browser.safebrowsing.downloads.enabled", Val::Bool(false))
422 .with_comment("Enable Safe Browsing download checks"),
423 Pref::new("browser.safebrowsing.blockedURIs.enabled", Val::Bool(false))
424 .with_comment("Enable Safe Browsing blocked URIs"),
425 Pref::new("network.captive-portal-service.enabled", Val::Bool(false))
427 .with_comment("Enable captive portal detection"),
428 Pref::new("network.connectivity-service.enabled", Val::Bool(false))
429 .with_comment("Enable connectivity service"),
430 Pref::new("network.dns.disableIPv6", Val::Bool(true))
435 .with_comment("Disable IPv6 DNS lookups"),
436 Pref::new("network.proxy.socks_remote_dns", Val::Bool(true))
437 .with_comment("Force DNS through SOCKS proxy"),
438 Pref::new("network.trr.mode", Val::Int(3))
440 .with_comment("TRR mode: 0=off, 2=TRR first, 3=TRR only"),
441 Pref::new(
442 "network.trr.uri",
443 Val::String("https://cloudflare-dns.com/dns-query".into()),
444 )
445 .with_comment("DNS-over-HTTPS server URI"),
446 Pref::new("network.trr.bootstrapAddr", Val::String("1.1.1.1".into()))
447 .with_comment("Bootstrap address for TRR"),
448 Pref::new("network.dns.echconfig.enabled", Val::Bool(false))
449 .with_comment("Enable ECH (Encrypted Client Hello)"),
450 Pref::new("network.dns.http3_echconfig.enabled", Val::Bool(false))
451 .with_comment("Enable HTTP/3 ECH"),
452 Pref::new("security.OCSP.enabled", Val::Int(0))
453 .with_comment("OCSP: 0=disabled, 1=enabled, 2=enabled for EV only"),
454 Pref::new("security.ssl.enable_ocsp_stapling", Val::Bool(false))
455 .with_comment("Enable OCSP stapling"),
456 Pref::new("security.ssl.enable_ocsp_must_staple", Val::Bool(false))
457 .with_comment("Enable OCSP must-staple"),
458 Pref::new("network.dns.disablePrefetch", Val::Bool(true))
463 .with_comment("Disable DNS prefetching"),
464 Pref::new("network.dns.disablePrefetchFromHTTPS", Val::Bool(true))
465 .with_comment("Disable DNS prefetch from HTTPS pages"),
466 Pref::new("network.prefetch-next", Val::Bool(false))
468 .with_comment("Enable prefetch service for link rel=next/prefetch"),
469 Pref::new("network.http.speculative-parallel-limit", Val::Int(0))
471 .with_comment("Max global half open sockets for speculative connections"),
472 Pref::new("network.predictor.enabled", Val::Bool(false))
473 .with_comment("Enable network predictor"),
474 Pref::new("network.predictor.enable-prefetch", Val::Bool(false))
475 .with_comment("Enable network predictor prefetch"),
476 Pref::new(
478 "browser.urlbar.speculativeConnect.enabled",
479 Val::Bool(false),
480 )
481 .with_comment("Warm up network connections for autofill/search results"),
482 Pref::new(
484 "browser.places.speculativeConnect.enabled",
485 Val::Bool(false),
486 )
487 .with_comment("Warm up network connections for places"),
488 Pref::new("browser.urlbar.suggest.searches", Val::Bool(false))
489 .with_comment("Suggest searches in URL bar"),
490 Pref::new("media.peerconnection.enabled", Val::Bool(false))
495 .with_comment("Enable WebRTC peer connections"),
496 Pref::new(
497 "media.peerconnection.ice.default_address_only",
498 Val::Bool(true),
499 )
500 .with_comment("Only use default address for ICE candidates"),
501 Pref::new("media.peerconnection.ice.no_host", Val::Bool(true))
502 .with_comment("Don't include host candidates in ICE"),
503 Pref::new(
504 "media.peerconnection.ice.proxy_only_if_behind_proxy",
505 Val::Bool(true),
506 )
507 .with_comment("Only use proxy for ICE if behind proxy"),
508 Pref::new("dom.push.enabled", Val::Bool(false)).with_comment("Enable Push API"),
513 Pref::new("dom.push.connection.enabled", Val::Bool(false))
515 .with_comment("Enable Push API network connection"),
516 Pref::new("beacon.enabled", Val::Bool(false)).with_comment("Enable Beacon API"),
517 Pref::new("privacy.resistFingerprinting", Val::Bool(false))
522 .with_comment("Enable fingerprinting resistance"),
523 Pref::new("dom.ipc.processCount", Val::Int(1))
529 .with_comment("Number of content processes"),
530 Pref::new("browser.tabs.remote.autostart", Val::Bool(true))
531 .with_comment("Enable multi-process tabs"),
532 ]
533 }
534}
535
536impl Profile {
541 pub fn install_extension(&self, source: &ExtensionSource) -> Result<()> {
551 match source {
552 ExtensionSource::Unpacked(path) => {
553 debug!(path = %path.display(), "Installing unpacked extension");
554 self.install_unpacked(path)
555 }
556 ExtensionSource::Packed(path) => {
557 debug!(path = %path.display(), "Installing packed extension");
558 self.install_packed(path)
559 }
560 ExtensionSource::Base64(data) => {
561 debug!("Installing base64 extension");
562 self.install_base64(data)
563 }
564 }
565 }
566
567 fn install_unpacked(&self, src: &Path) -> Result<()> {
569 let extension_id = self.read_manifest_id(src)?;
570 let dest = self.extensions_dir().join(&extension_id);
571
572 copy_dir_recursive(src, &dest)?;
573
574 debug!(
575 extension_id = %extension_id,
576 dest = %dest.display(),
577 "Installed unpacked extension"
578 );
579
580 Ok(())
581 }
582
583 fn install_packed(&self, src: &Path) -> Result<()> {
585 let file = fs::File::open(src).map_err(Error::Io)?;
586 let mut archive = ZipArchive::new(file)
587 .map_err(|e| Error::profile(format!("Invalid extension archive: {}", e)))?;
588
589 let temp_extract = TempDir::new().map_err(Error::Io)?;
590 archive
591 .extract(temp_extract.path())
592 .map_err(|e| Error::profile(format!("Failed to extract extension: {}", e)))?;
593
594 self.install_unpacked(temp_extract.path())
595 }
596
597 fn install_base64(&self, data: &str) -> Result<()> {
599 let bytes = Base64Standard
601 .decode(data)
602 .map_err(|e| Error::profile(format!("Invalid base64 extension data: {}", e)))?;
603
604 let temp_dir = TempDir::new().map_err(Error::Io)?;
606 let temp_xpi = temp_dir.path().join("extension.xpi");
607 fs::write(&temp_xpi, bytes).map_err(Error::Io)?;
608
609 self.install_packed(&temp_xpi)
611 }
612
613 fn read_manifest_id(&self, dir: &Path) -> Result<String> {
615 let manifest_path = dir.join("manifest.json");
616 let content = fs::read_to_string(&manifest_path).map_err(|e| {
617 Error::profile(format!(
618 "Extension manifest not found at {}: {}",
619 manifest_path.display(),
620 e
621 ))
622 })?;
623
624 let json: Value = from_str(&content)
625 .map_err(|e| Error::profile(format!("Invalid manifest.json: {}", e)))?;
626
627 if let Some(id) = json.pointer("/browser_specific_settings/gecko/id")
629 && let Some(id_str) = id.as_str()
630 {
631 return Ok(id_str.to_string());
632 }
633
634 if let Some(id) = json.pointer("/applications/gecko/id")
635 && let Some(id_str) = id.as_str()
636 {
637 return Ok(id_str.to_string());
638 }
639
640 Err(Error::profile(
641 "Extension manifest missing 'gecko.id' field".to_string(),
642 ))
643 }
644}
645
646fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
652 if !dst.exists() {
653 fs::create_dir_all(dst).map_err(Error::Io)?;
654 }
655
656 for entry in fs::read_dir(src).map_err(Error::Io)? {
657 let entry = entry.map_err(Error::Io)?;
658 let file_type = entry.file_type().map_err(Error::Io)?;
659 let src_path = entry.path();
660 let dst_path = dst.join(entry.file_name());
661
662 if file_type.is_dir() {
663 copy_dir_recursive(&src_path, &dst_path)?;
664 } else {
665 fs::copy(&src_path, &dst_path).map_err(Error::Io)?;
666 }
667 }
668
669 Ok(())
670}
671
672#[cfg(test)]
677mod tests {
678 use super::Profile;
679
680 #[test]
681 fn test_new_temp_creates_directory() {
682 let profile = Profile::new_temp().expect("create temp profile");
683 assert!(profile.path().exists());
684 assert!(profile.path().is_dir());
685 }
686
687 #[test]
688 fn test_temp_profile_cleanup_on_drop() {
689 let path = {
690 let profile = Profile::new_temp().expect("create temp profile");
691 let path = profile.path().to_path_buf();
692 assert!(path.exists());
693 path
694 };
695 assert!(!path.exists());
696 }
697
698 #[test]
699 fn test_default_prefs_not_empty() {
700 let prefs = Profile::default_prefs();
701 assert!(!prefs.is_empty());
702 }
703
704 #[test]
705 fn test_from_path_creates_directory() {
706 let temp = tempfile::tempdir().expect("create temp dir");
707 let profile_path = temp.path().join("test_profile");
708
709 assert!(!profile_path.exists());
710 let profile = Profile::from_path(&profile_path).expect("create profile");
711 assert!(profile.path().exists());
712 }
713}