Skip to main content

firefox_webdriver/driver/profile/
mod.rs

1//! Firefox profile management and configuration.
2//!
3//! This module handles the creation and configuration of Firefox profiles,
4//! including:
5//!
6//! - Creating temporary profiles with automatic cleanup
7//! - Using existing profile directories
8//! - Writing preferences (`user.js`)
9//! - Installing extensions
10//!
11//! # Example
12//!
13//! ```no_run
14//! use firefox_webdriver::driver::profile::{Profile, ExtensionSource};
15//!
16//! # fn example() -> firefox_webdriver::Result<()> {
17//! let profile = Profile::new_temp()?;
18//!
19//! // Write default preferences
20//! let prefs = Profile::default_prefs();
21//! profile.write_prefs(&prefs)?;
22//!
23//! // Install extension
24//! let ext = ExtensionSource::unpacked("./extension");
25//! profile.install_extension(&ext)?;
26//! # Ok(())
27//! # }
28//! ```
29
30// ============================================================================
31// Imports
32// ============================================================================
33
34use 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
46// ============================================================================
47// Submodules
48// ============================================================================
49
50/// Extension installation and management.
51pub mod extensions;
52
53/// Firefox preference definitions and serialization.
54pub mod preferences;
55
56// ============================================================================
57// Re-exports
58// ============================================================================
59
60pub use extensions::ExtensionSource;
61pub use preferences::{FirefoxPreference, PreferenceValue};
62
63// ============================================================================
64// Constants
65// ============================================================================
66
67/// Header comment for `user.js` file.
68const USER_JS_HEADER: &str = "// Firefox WebDriver user.js\n\
69                              // Auto-generated preferences for automation\n\n";
70
71// ============================================================================
72// Profile
73// ============================================================================
74
75/// A Firefox profile directory.
76///
77/// Manages a Firefox profile, which contains settings, extensions, and state.
78/// Profiles can be temporary (auto-cleanup) or persistent (user-managed).
79///
80/// # Temporary Profiles
81///
82/// Created with [`Profile::new_temp()`], these are automatically deleted
83/// when the `Profile` is dropped.
84///
85/// # Persistent Profiles
86///
87/// Created with [`Profile::from_path()`], these persist after the program exits.
88pub struct Profile {
89    /// Optional temporary directory handle (keeps temp dir alive).
90    _temp_dir: Option<TempDir>,
91
92    /// Path to the profile directory.
93    path: PathBuf,
94}
95
96// ============================================================================
97// Profile - Constructors
98// ============================================================================
99
100impl Profile {
101    /// Creates a new temporary profile.
102    ///
103    /// The profile directory is created in the system temp directory with
104    /// a unique name. It is automatically deleted when the Profile is dropped.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if the temporary directory cannot be created.
109    ///
110    /// # Example
111    ///
112    /// ```no_run
113    /// use firefox_webdriver::driver::profile::Profile;
114    ///
115    /// # fn example() -> firefox_webdriver::Result<()> {
116    /// let profile = Profile::new_temp()?;
117    /// println!("Profile at: {}", profile.path().display());
118    /// # Ok(())
119    /// # }
120    /// ```
121    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    /// Uses an existing profile directory.
135    ///
136    /// If the directory doesn't exist, it is created.
137    ///
138    /// # Arguments
139    ///
140    /// * `path` - Path to the profile directory
141    ///
142    /// # Errors
143    ///
144    /// Returns an error if the directory cannot be created.
145    ///
146    /// # Example
147    ///
148    /// ```no_run
149    /// use firefox_webdriver::driver::profile::Profile;
150    ///
151    /// # fn example() -> firefox_webdriver::Result<()> {
152    /// let profile = Profile::from_path("./my_profile")?;
153    /// # Ok(())
154    /// # }
155    /// ```
156    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
179// ============================================================================
180// Profile - Accessors
181// ============================================================================
182
183impl Profile {
184    /// Returns the path to the profile directory.
185    #[inline]
186    #[must_use]
187    pub fn path(&self) -> &Path {
188        &self.path
189    }
190
191    /// Returns the extensions directory, creating it if necessary.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the directory cannot be created.
196    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
211// ============================================================================
212// Profile - Preferences
213// ============================================================================
214
215impl Profile {
216    /// Writes preferences to `user.js`.
217    ///
218    /// # Arguments
219    ///
220    /// * `prefs` - Slice of preferences to write
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if the file cannot be written.
225    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    /// Returns the default preferences for WebDriver automation.
252    ///
253    /// These preferences configure Firefox for automation:
254    /// - Allow unsigned extensions
255    /// - Disable telemetry
256    /// - Disable updates
257    /// - Enable fingerprint randomization
258    #[must_use]
259    pub fn default_prefs() -> Vec<FirefoxPreference> {
260        use preferences::{FirefoxPreference as Pref, PreferenceValue as Val};
261
262        vec![
263            // ================================================================
264            // SECTION 1: WebDriver Extension Support
265            // Source: modules/libpref/init/all.js, browser/app/profile/firefox.js
266            // ================================================================
267            // Only Firefox requires add-on signatures (firefox.js)
268            Pref::new("xpinstall.signatures.required", Val::Bool(false))
269                .with_comment("Only Firefox requires add-on signatures"),
270            // Disable add-ons not installed by user in all scopes (firefox.js)
271            Pref::new("extensions.autoDisableScopes", Val::Int(0))
272                .with_comment("Disable add-ons that are not installed by the user in all scopes"),
273            // Restricted domains for webextensions (all.js)
274            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            // ================================================================
285            // SECTION 2: Fast Startup (Skip UI prompts)
286            // Source: browser/app/profile/firefox.js
287            // ================================================================
288            // 0 = blank, 1 = home, 2 = last visited page, 3 = resume previous session
289            Pref::new("browser.startup.page", Val::Int(0))
290                .with_comment("0 = blank, 1 = home, 2 = last visited, 3 = resume session"),
291            // At startup, check if we're the default browser and prompt user if not
292            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            // Number of crashes that can occur before about:sessionrestore is displayed
302            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            // browser.warnOnQuit == false will override all other possible prompts
309            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            // This records whether or not the panel has been shown at least once
323            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            // UI tour experience (firefox.js)
339            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            // ================================================================
351            // SECTION 3: Disable Telemetry & Data Collection
352            // Source: modules/libpref/init/all.js
353            // ================================================================
354            // Whether to use the unified telemetry behavior, requires a restart
355            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            // Server to submit telemetry pings to
360            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            // ================================================================
405            // SECTION 4: Disable Auto-Updates
406            // Source: browser/app/profile/firefox.js
407            // ================================================================
408            // If set to true, the Update Service will apply updates in the background
409            Pref::new("app.update.staging.enabled", Val::Bool(false))
410                .with_comment("Apply updates in the background when finished downloading"),
411            // Whether or not to attempt using the service for updates
412            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            // ================================================================
421            // SECTION 5: Disable Background Services
422            // ================================================================
423            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            // Enable captive portal detection (firefox.js)
436            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            // ================================================================
441            // SECTION 6: Privacy - DNS Leak Prevention
442            // Source: modules/libpref/init/all.js
443            // ================================================================
444            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            // 0=off, 1=reserved, 2=TRR first, 3=TRR only, 4=reserved, 5=off by choice
449            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            // ================================================================
469            // SECTION 7: Privacy - Disable Prefetching & Speculative Connections
470            // Source: modules/libpref/init/all.js
471            // ================================================================
472            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            // Enables the prefetch service (prefetching of <link rel=\"next\">)
477            Pref::new("network.prefetch-next", Val::Bool(false))
478                .with_comment("Enable prefetch service for link rel=next/prefetch"),
479            // The maximum number of current global half open sockets for speculative connections
480            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            // Whether to warm up network connections for autofill or search results
487            Pref::new(
488                "browser.urlbar.speculativeConnect.enabled",
489                Val::Bool(false),
490            )
491            .with_comment("Warm up network connections for autofill/search results"),
492            // Whether to warm up network connections for places
493            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            // ================================================================
501            // SECTION 8: Privacy - WebRTC Leak Prevention
502            // Source: modules/libpref/init/all.js (MOZ_WEBRTC section)
503            // ================================================================
504            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            // ================================================================
519            // SECTION 9: Privacy - Disable Other Leak Vectors
520            // Source: modules/libpref/init/all.js
521            // ================================================================
522            Pref::new("dom.push.enabled", Val::Bool(false)).with_comment("Enable Push API"),
523            // Is the network connection allowed to be up?
524            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            // ================================================================
528            // SECTION 10: Fingerprint Protection
529            // Source: modules/libpref/init/all.js
530            // ================================================================
531            Pref::new("privacy.resistFingerprinting", Val::Bool(false))
532                .with_comment("Disable fingerprinting resistance (intentional - RFP artifacts are detectable)"),
533            // ================================================================
534            // SECTION 11: Performance
535            // Source: modules/libpref/init/all.js
536            // ================================================================
537            // Enable multi by default (all.js)
538            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
546// ============================================================================
547// Profile - Extensions
548// ============================================================================
549
550impl Profile {
551    /// Installs an extension into the profile.
552    ///
553    /// # Arguments
554    ///
555    /// * `source` - Extension source (unpacked, packed, or base64)
556    ///
557    /// # Errors
558    ///
559    /// Returns an error if installation fails.
560    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    /// Installs an unpacked extension directory.
578    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    /// Installs a packed extension (.xpi or .zip).
594    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    /// Installs a base64-encoded extension.
608    fn install_base64(&self, data: &str) -> Result<()> {
609        // Decode base64
610        let bytes = Base64Standard
611            .decode(data)
612            .map_err(|e| Error::profile(format!("Invalid base64 extension data: {}", e)))?;
613
614        // Write to temp file
615        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        // Install as packed
620        self.install_packed(&temp_xpi)
621    }
622
623    /// Reads the extension ID from manifest.json.
624    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        // Try standard WebExtension ID locations
638        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
656// ============================================================================
657// Private Helpers
658// ============================================================================
659
660/// Recursively copies a directory and all its contents.
661///
662/// Symlinks are skipped to prevent infinite recursion from circular links.
663fn 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        // Skip symlinks to prevent infinite recursion
673        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// ============================================================================
688// Tests
689// ============================================================================
690
691#[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}