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    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
201// ============================================================================
202// Profile - Preferences
203// ============================================================================
204
205impl Profile {
206    /// Writes preferences to `user.js`.
207    ///
208    /// # Arguments
209    ///
210    /// * `prefs` - Slice of preferences to write
211    ///
212    /// # Errors
213    ///
214    /// Returns an error if the file cannot be written.
215    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    /// Returns the default preferences for WebDriver automation.
242    ///
243    /// These preferences configure Firefox for automation:
244    /// - Allow unsigned extensions
245    /// - Disable telemetry
246    /// - Disable updates
247    /// - Enable fingerprint randomization
248    #[must_use]
249    pub fn default_prefs() -> Vec<FirefoxPreference> {
250        use preferences::{FirefoxPreference as Pref, PreferenceValue as Val};
251
252        vec![
253            // ================================================================
254            // SECTION 1: WebDriver Extension Support
255            // Source: modules/libpref/init/all.js, browser/app/profile/firefox.js
256            // ================================================================
257            // Only Firefox requires add-on signatures (firefox.js)
258            Pref::new("xpinstall.signatures.required", Val::Bool(false))
259                .with_comment("Only Firefox requires add-on signatures"),
260            // Disable add-ons not installed by user in all scopes (firefox.js)
261            Pref::new("extensions.autoDisableScopes", Val::Int(0))
262                .with_comment("Disable add-ons that are not installed by the user in all scopes"),
263            // Restricted domains for webextensions (all.js)
264            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            // ================================================================
275            // SECTION 2: Fast Startup (Skip UI prompts)
276            // Source: browser/app/profile/firefox.js
277            // ================================================================
278            // 0 = blank, 1 = home, 2 = last visited page, 3 = resume previous session
279            Pref::new("browser.startup.page", Val::Int(0))
280                .with_comment("0 = blank, 1 = home, 2 = last visited, 3 = resume session"),
281            // At startup, check if we're the default browser and prompt user if not
282            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            // Number of crashes that can occur before about:sessionrestore is displayed
292            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            // browser.warnOnQuit == false will override all other possible prompts
299            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            // This records whether or not the panel has been shown at least once
313            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            // UI tour experience (firefox.js)
329            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            // ================================================================
341            // SECTION 3: Disable Telemetry & Data Collection
342            // Source: modules/libpref/init/all.js
343            // ================================================================
344            // Whether to use the unified telemetry behavior, requires a restart
345            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            // Server to submit telemetry pings to
350            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            // ================================================================
395            // SECTION 4: Disable Auto-Updates
396            // Source: browser/app/profile/firefox.js
397            // ================================================================
398            // If set to true, the Update Service will apply updates in the background
399            Pref::new("app.update.staging.enabled", Val::Bool(false))
400                .with_comment("Apply updates in the background when finished downloading"),
401            // Whether or not to attempt using the service for updates
402            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            // ================================================================
411            // SECTION 5: Disable Background Services
412            // ================================================================
413            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            // Enable captive portal detection (firefox.js)
426            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            // ================================================================
431            // SECTION 6: Privacy - DNS Leak Prevention
432            // Source: modules/libpref/init/all.js
433            // ================================================================
434            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            // 0=off, 1=reserved, 2=TRR first, 3=TRR only, 4=reserved, 5=off by choice
439            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            // ================================================================
459            // SECTION 7: Privacy - Disable Prefetching & Speculative Connections
460            // Source: modules/libpref/init/all.js
461            // ================================================================
462            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            // Enables the prefetch service (prefetching of <link rel=\"next\">)
467            Pref::new("network.prefetch-next", Val::Bool(false))
468                .with_comment("Enable prefetch service for link rel=next/prefetch"),
469            // The maximum number of current global half open sockets for speculative connections
470            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            // Whether to warm up network connections for autofill or search results
477            Pref::new(
478                "browser.urlbar.speculativeConnect.enabled",
479                Val::Bool(false),
480            )
481            .with_comment("Warm up network connections for autofill/search results"),
482            // Whether to warm up network connections for places
483            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            // ================================================================
491            // SECTION 8: Privacy - WebRTC Leak Prevention
492            // Source: modules/libpref/init/all.js (MOZ_WEBRTC section)
493            // ================================================================
494            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            // ================================================================
509            // SECTION 9: Privacy - Disable Other Leak Vectors
510            // Source: modules/libpref/init/all.js
511            // ================================================================
512            Pref::new("dom.push.enabled", Val::Bool(false)).with_comment("Enable Push API"),
513            // Is the network connection allowed to be up?
514            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            // ================================================================
518            // SECTION 10: Fingerprint Protection
519            // Source: modules/libpref/init/all.js
520            // ================================================================
521            Pref::new("privacy.resistFingerprinting", Val::Bool(false))
522                .with_comment("Enable fingerprinting resistance"),
523            // ================================================================
524            // SECTION 11: Performance
525            // Source: modules/libpref/init/all.js
526            // ================================================================
527            // Enable multi by default (all.js)
528            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
536// ============================================================================
537// Profile - Extensions
538// ============================================================================
539
540impl Profile {
541    /// Installs an extension into the profile.
542    ///
543    /// # Arguments
544    ///
545    /// * `source` - Extension source (unpacked, packed, or base64)
546    ///
547    /// # Errors
548    ///
549    /// Returns an error if installation fails.
550    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    /// Installs an unpacked extension directory.
568    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    /// Installs a packed extension (.xpi or .zip).
584    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    /// Installs a base64-encoded extension.
598    fn install_base64(&self, data: &str) -> Result<()> {
599        // Decode base64
600        let bytes = Base64Standard
601            .decode(data)
602            .map_err(|e| Error::profile(format!("Invalid base64 extension data: {}", e)))?;
603
604        // Write to temp file
605        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        // Install as packed
610        self.install_packed(&temp_xpi)
611    }
612
613    /// Reads the extension ID from manifest.json.
614    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        // Try standard WebExtension ID locations
628        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
646// ============================================================================
647// Private Helpers
648// ============================================================================
649
650/// Recursively copies a directory and all its contents.
651fn 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// ============================================================================
673// Tests
674// ============================================================================
675
676#[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}