diff --git a/Cargo.lock b/Cargo.lock index d328d42e1101..7242103ecefb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4013,6 +4013,17 @@ dependencies = [ "libc", ] +[[package]] +name = "nmhproxy" +version = "0.1.0" +dependencies = [ + "mozbuild", + "mozilla-central-workspace-hack", + "serde", + "serde_json", + "url", +] + [[package]] name = "nom" version = "7.1.3" diff --git a/Cargo.toml b/Cargo.toml index 54decc93acdf..dd0ed4f7c2db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ # and do not need to be listed here. Their external dependencies are vendored # into `third_party/rust` by `mach vendor rust`. members = [ + "browser/app/nmhproxy/", "js/src/frontend/smoosh", "js/src/rust", "netwerk/test/http3server", diff --git a/browser/app/macbuild/Contents/MacOS-files.in b/browser/app/macbuild/Contents/MacOS-files.in index e3ed3b7b94ed..8c43996f34b9 100644 --- a/browser/app/macbuild/Contents/MacOS-files.in +++ b/browser/app/macbuild/Contents/MacOS-files.in @@ -16,6 +16,7 @@ #if defined(MOZ_CRASHREPORTER) /minidump-analyzer #endif +/nmhproxy /pingsender /pk12util /ssltunnel diff --git a/browser/app/moz.build b/browser/app/moz.build index a933a3cb9bdf..c731e9798a85 100644 --- a/browser/app/moz.build +++ b/browser/app/moz.build @@ -135,6 +135,9 @@ if CONFIG["MOZ_SANDBOX"] and CONFIG["OS_ARCH"] == "WINNT": "usp10.dll", ] +if CONFIG["TARGET_OS"] in ("WINNT", "OSX"): + DIRS += ["nmhproxy"] + # Control the default heap size. # This is the heap returned by GetProcessHeap(). # As we use the CRT heap, the default size is too large and wastes VM. diff --git a/browser/app/nmhproxy/Cargo.toml b/browser/app/nmhproxy/Cargo.toml new file mode 100644 index 000000000000..14746d51b619 --- /dev/null +++ b/browser/app/nmhproxy/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nmhproxy" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" +description = "A lightweight native messaging listener executable for the Firefox Bridge extension which launches Firefox in regular or private modes, avoiding the need to convert Firefox itself into a listener." + +[[bin]] +name = "nmhproxy" +path = "src/main.rs" + +[dependencies] +mozbuild = "0.1" +mozilla-central-workspace-hack = { version = "0.1", features = ["nmhproxy"], optional = true } +serde = { version = "1", features = ["derive", "rc"] } +serde_json = "1.0" +url = "2.4" diff --git a/browser/app/nmhproxy/moz.build b/browser/app/nmhproxy/moz.build new file mode 100644 index 000000000000..1f12e2880deb --- /dev/null +++ b/browser/app/nmhproxy/moz.build @@ -0,0 +1,15 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +RUST_PROGRAMS += [ + "nmhproxy", +] + +# Ideally, the build system would set @rpath to be @executable_path as +# a default for this executable so that this addition to LDFLAGS would not be +# needed here. Bug 1772575 is filed to implement that. +if CONFIG["OS_ARCH"] == "Darwin": + LDFLAGS += ["-Wl,-rpath,@executable_path"] diff --git a/browser/app/nmhproxy/src/commands.rs b/browser/app/nmhproxy/src/commands.rs new file mode 100644 index 000000000000..29c86a0dd7b5 --- /dev/null +++ b/browser/app/nmhproxy/src/commands.rs @@ -0,0 +1,350 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use serde::{Deserialize, Serialize}; +use std::io::{self, Read, Write}; +use std::process::Command; +use url::Url; + +#[cfg(target_os = "windows")] +const OS_NAME: &str = "windows"; + +#[cfg(target_os = "macos")] +const OS_NAME: &str = "macos"; + +#[derive(Serialize, Deserialize)] +#[serde(tag = "command", content = "data")] +// { +// "command": "LaunchFirefox", +// "data": {"url": "https://example.com"}, +// } +pub enum FirefoxCommand { + LaunchFirefox { url: String }, + LaunchFirefoxPrivate { url: String }, + GetVersion {}, +} +#[derive(Serialize, Deserialize)] +// { +// "message": "Successful launch", +// "result_code": 1, +// } +pub struct Response { + pub message: String, + pub result_code: u32, +} + +#[repr(u32)] +pub enum ResultCode { + Success = 0, + Error = 1, +} +impl From for u32 { + fn from(m: ResultCode) -> u32 { + m as u32 + } +} + +trait CommandRunner { + fn new() -> Self + where + Self: Sized; + fn arg(&mut self, arg: &str) -> &mut Self; + fn args(&mut self, args: &[&str]) -> &mut Self; + fn spawn(&mut self) -> std::io::Result<()>; + fn to_string(&mut self) -> std::io::Result; +} + +impl CommandRunner for Command { + fn new() -> Self { + #[cfg(target_os = "macos")] + { + Command::new("open") + } + #[cfg(target_os = "windows")] + { + use mozbuild::config::MOZ_APP_NAME; + use std::env; + use std::path::Path; + // Get the current executable's path, we know Firefox is in the + // same folder is nmhproxy.exe so we can use that. + let nmh_exe_path = env::current_exe().unwrap(); + let nmh_exe_folder = nmh_exe_path.parent().unwrap_or_else(|| Path::new("")); + let moz_exe_path = nmh_exe_folder.join(format!("{}.exe", MOZ_APP_NAME)); + Command::new(moz_exe_path) + } + } + fn arg(&mut self, arg: &str) -> &mut Self { + self.arg(arg) + } + fn args(&mut self, args: &[&str]) -> &mut Self { + self.args(args) + } + fn spawn(&mut self) -> std::io::Result<()> { + self.spawn().map(|_| ()) + } + fn to_string(&mut self) -> std::io::Result { + Ok("".to_string()) + } +} + +struct MockCommand { + command_line: String, +} + +impl CommandRunner for MockCommand { + fn new() -> Self { + MockCommand { + command_line: String::new(), + } + } + fn arg(&mut self, arg: &str) -> &mut Self { + self.command_line.push_str(arg); + self.command_line.push(' '); + self + } + fn args(&mut self, args: &[&str]) -> &mut Self { + for arg in args { + self.command_line.push_str(arg); + self.command_line.push(' '); + } + self + } + fn spawn(&mut self) -> std::io::Result<()> { + Ok(()) + } + fn to_string(&mut self) -> std::io::Result { + Ok(self.command_line.clone()) + } +} + +// The message length is a 32-bit integer in native byte order +pub fn read_message_length(mut reader: R) -> std::io::Result { + let mut buffer = [0u8; 4]; + reader.read_exact(&mut buffer)?; + let length: u32 = u32::from_ne_bytes(buffer); + if (length > 0) && (length < 100 * 1024) { + Ok(length) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "Invalid message length", + )) + } +} + +pub fn read_message_string(mut reader: R, length: u32) -> io::Result { + let mut buffer = vec![0u8; length.try_into().unwrap()]; + reader.read_exact(&mut buffer)?; + let message = + String::from_utf8(buffer).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + Ok(message) +} + +pub fn process_command(command: &FirefoxCommand) -> std::io::Result { + match &command { + FirefoxCommand::LaunchFirefox { url } => { + launch_firefox::(url.to_owned(), false, OS_NAME)?; + Ok(true) + } + FirefoxCommand::LaunchFirefoxPrivate { url } => { + launch_firefox::(url.to_owned(), true, OS_NAME)?; + Ok(true) + } + FirefoxCommand::GetVersion {} => generate_response("1", ResultCode::Success.into()), + } +} + +pub fn generate_response(message: &str, result_code: u32) -> std::io::Result { + let response_struct = Response { + message: message.to_string(), + result_code, + }; + let response_str = serde_json::to_string(&response_struct) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let response_len_bytes: [u8; 4] = (response_str.len() as u32).to_ne_bytes(); + std::io::stdout().write_all(&response_len_bytes)?; + std::io::stdout().write_all(response_str.as_bytes())?; + std::io::stdout().flush()?; + Ok(true) +} + +fn validate_url(url: String) -> std::io::Result { + let parsed_url = Url::parse(url.as_str()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + match parsed_url.scheme() { + "http" | "https" | "file" => Ok(parsed_url.to_string()), + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Invalid URL scheme", + )), + } +} + +fn launch_firefox( + url: String, + private: bool, + os: &str, +) -> std::io::Result { + let validated_url: String = validate_url(url)?; + let mut command = C::new(); + if os == "macos" { + use mozbuild::config::MOZ_MACBUNDLE_ID; + let mut args: [&str; 2] = ["--args", "-url"]; + if private { + args[1] = "-private-window"; + } + command + .arg("-n") + .arg("-b") + .arg(MOZ_MACBUNDLE_ID) + .args(&args) + .arg(validated_url.as_str()); + } else if os == "windows" { + let mut args: [&str; 2] = ["-osint", "-url"]; + if private { + args[1] = "-private-window"; + } + command.args(&args).arg(validated_url.as_str()); + } + match command.spawn() { + Ok(_) => generate_response( + if private { + "Successful private launch" + } else { + "Sucessful launch" + }, + ResultCode::Success.into(), + )?, + Err(_) => generate_response( + if private { + "Failed private launch" + } else { + "Failed launch" + }, + ResultCode::Error.into(), + )?, + }; + command.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + #[test] + fn test_validate_url() { + let valid_test_cases = vec![ + "https://example.com/".to_string(), + "http://example.com/".to_string(), + "file:///path/to/file".to_string(), + "https://test.example.com/".to_string(), + ]; + + for input in valid_test_cases { + let result = validate_url(input.clone()); + assert!(result.is_ok(), "Expected Ok, got Err"); + // Safe to unwrap because we know the result is Ok + let ok_value = result.unwrap(); + assert_eq!(ok_value, input); + } + + assert!(matches!( + validate_url("fakeprotocol://test.example.com/".to_string()).map_err(|e| e.kind()), + Err(std::io::ErrorKind::InvalidInput) + )); + + assert!(matches!( + validate_url("invalidURL".to_string()).map_err(|e| e.kind()), + Err(std::io::ErrorKind::InvalidData) + )); + } + + #[test] + fn test_read_message_length_valid() { + let input: [u8; 4] = 256u32.to_ne_bytes(); + let mut cursor = Cursor::new(input); + let length = read_message_length(&mut cursor); + assert!(length.is_ok(), "Expected Ok, got Err"); + assert_eq!(length.unwrap(), 256); + } + + #[test] + fn test_read_message_length_invalid_too_large() { + let input: [u8; 4] = 1_000_000u32.to_ne_bytes(); + let mut cursor = Cursor::new(input); + let result = read_message_length(&mut cursor); + assert!(result.is_err()); + let error = result.err().unwrap(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn test_read_message_length_invalid_zero() { + let input: [u8; 4] = 0u32.to_ne_bytes(); + let mut cursor = Cursor::new(input); + let result = read_message_length(&mut cursor); + assert!(result.is_err()); + let error = result.err().unwrap(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn test_read_message_string_valid() { + let input_data = b"Valid UTF8 string!"; + let input_length = input_data.len() as u32; + let message = read_message_string(&input_data[..], input_length); + assert!(message.is_ok(), "Expected Ok, got Err"); + assert_eq!(message.unwrap(), "Valid UTF8 string!"); + } + + #[test] + fn test_read_message_string_invalid() { + let input_data: [u8; 3] = [0xff, 0xfe, 0xfd]; + let input_length = input_data.len() as u32; + let result = read_message_string(&input_data[..], input_length); + assert!(result.is_err()); + let error = result.err().unwrap(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn test_launch_regular_command_macos() { + let url = "https://example.com"; + let result = launch_firefox::(url.to_string(), false, "macos"); + assert!(result.is_ok()); + let command_line = result.unwrap(); + let correct_url_format = format!("-url {}", url); + assert!(command_line.contains(correct_url_format.as_str())); + } + + #[test] + fn test_launch_regular_command_windows() { + let url = "https://example.com"; + let result = launch_firefox::(url.to_string(), false, "windows"); + assert!(result.is_ok()); + let command_line = result.unwrap(); + let correct_url_format = format!("-osint -url {}", url); + assert!(command_line.contains(correct_url_format.as_str())); + } + + #[test] + fn test_launch_private_command_macos() { + let url = "https://example.com"; + let result = launch_firefox::(url.to_string(), true, "macos"); + assert!(result.is_ok()); + let command_line = result.unwrap(); + let correct_url_format = format!("-private-window {}", url); + assert!(command_line.contains(correct_url_format.as_str())); + } + + #[test] + fn test_launch_private_command_windows() { + let url = "https://example.com"; + let result = launch_firefox::(url.to_string(), true, "windows"); + assert!(result.is_ok()); + let command_line = result.unwrap(); + let correct_url_format = format!("-osint -private-window {}", url); + assert!(command_line.contains(correct_url_format.as_str())); + } +} diff --git a/browser/app/nmhproxy/src/main.rs b/browser/app/nmhproxy/src/main.rs new file mode 100644 index 000000000000..de9cd8c2a322 --- /dev/null +++ b/browser/app/nmhproxy/src/main.rs @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +mod commands; +use commands::ResultCode; +use std::io::Error; +use std::io::ErrorKind; + +fn main() -> Result<(), Error> { + // The general structure of these functions is to print error cases to + // stdout so that the extension can read them and then do error-handling + // on that end. + let message_length: u32 = + commands::read_message_length(std::io::stdin()).or_else(|_| -> Result { + commands::generate_response("Failed to read message length", ResultCode::Error.into()) + .expect("JSON error"); + return Err(Error::new( + ErrorKind::InvalidInput, + "Failed to read message length", + )); + })?; + let message: String = commands::read_message_string(std::io::stdin(), message_length).or_else( + |_| -> Result { + commands::generate_response("Failed to read message", ResultCode::Error.into()) + .expect("JSON error"); + return Err(Error::new( + ErrorKind::InvalidInput, + "Failed to read message", + )); + }, + )?; + // Deserialize the message with the following expected format + let native_messaging_json: commands::FirefoxCommand = + serde_json::from_str(&message).or_else(|_| -> Result { + commands::generate_response( + "Failed to deserialize message JSON", + ResultCode::Error.into(), + ) + .expect("JSON error"); + return Err(Error::new( + ErrorKind::InvalidInput, + "Failed to deserialize message JSON", + )); + })?; + commands::process_command(&native_messaging_json).or_else(|_| -> Result { + commands::generate_response("Failed to process command", ResultCode::Error.into()) + .expect("JSON error"); + return Err(Error::new( + ErrorKind::InvalidInput, + "Failed to process command", + )); + })?; + Ok(()) +} diff --git a/browser/installer/package-manifest.in b/browser/installer/package-manifest.in index 59b5db1cf9ef..c35f5bc4fbdf 100644 --- a/browser/installer/package-manifest.in +++ b/browser/installer/package-manifest.in @@ -411,6 +411,12 @@ bin/libfreebl_64int_3.so ; @BINPATH@/pingsender@BIN_SUFFIX@ +; [ Native Messaging Host Proxy ] +; +#if defined(XP_WIN) || defined(XP_MACOSX) +@BINPATH@/nmhproxy@BIN_SUFFIX@ +#endif + ; [ Notification COM Server ] ; #if defined(MOZ_NOTIFICATION_SERVER) diff --git a/browser/installer/windows/nsis/shared.nsh b/browser/installer/windows/nsis/shared.nsh index c1a8f0f58f92..ccad601abeee 100755 --- a/browser/installer/windows/nsis/shared.nsh +++ b/browser/installer/windows/nsis/shared.nsh @@ -1577,6 +1577,7 @@ ${RemoveDefaultBrowserAgentShortcut} Push "crashreporter.exe" Push "default-browser-agent.exe" Push "minidump-analyzer.exe" + Push "nmhproxy.exe" Push "pingsender.exe" Push "updater.exe" Push "mozwer.dll" diff --git a/build/rust/mozbuild/generate_buildconfig.py b/build/rust/mozbuild/generate_buildconfig.py index 2252276319aa..09c32c37fd92 100644 --- a/build/rust/mozbuild/generate_buildconfig.py +++ b/build/rust/mozbuild/generate_buildconfig.py @@ -34,6 +34,14 @@ def escape_rust_string(value): return '"%s"' % result +def generate_string(buildvar, output): + buildconfig_var = buildconfig.substs.get(buildvar) + if buildconfig_var is not None: + output.write( + f"pub const {buildvar}: &str = {escape_rust_string(buildconfig_var)};\n" + ) + + def generate(output): # Write out a macro which can be used within `include!`-like methods to # reference the topobjdir. @@ -85,6 +93,10 @@ def generate(output): ) ) + # Write out some useful strings from the buildconfig. + generate_string("MOZ_MACBUNDLE_ID", output) + generate_string("MOZ_APP_NAME", output) + # Finally, write out some useful booleans from the buildconfig. output.write(generate_bool("MOZ_FOLD_LIBS")) output.write(generate_bool("NIGHTLY_BUILD")) diff --git a/build/workspace-hack/Cargo.toml b/build/workspace-hack/Cargo.toml index 6e3243d2112b..1869822aed2f 100644 --- a/build/workspace-hack/Cargo.toml +++ b/build/workspace-hack/Cargo.toml @@ -189,4 +189,5 @@ http3server = ["dep:arrayvec", "dep:bindgen", "dep:bitflags", "dep:bytes", "dep: ipcclientcerts-static = ["dep:bindgen", "dep:bitflags", "dep:memchr", "dep:nom", "dep:regex"] jsrust = ["dep:arrayvec", "dep:cc", "dep:env_logger", "dep:getrandom", "dep:hashbrown", "dep:indexmap", "dep:log", "dep:memchr", "dep:num-traits", "dep:once_cell", "dep:semver", "dep:smallvec", "dep:url"] mozwer_s = ["dep:getrandom", "dep:hashbrown", "dep:indexmap", "dep:once_cell", "dep:serde_json", "dep:uuid", "dep:windows-sys"] +nmhproxy = ["dep:serde_json", "dep:url"] osclientcerts-static = ["dep:bindgen", "dep:bitflags", "dep:env_logger", "dep:log", "dep:memchr", "dep:nom", "dep:regex"] diff --git a/python/mozbuild/mozbuild/artifacts.py b/python/mozbuild/mozbuild/artifacts.py index 774268ce47b9..eff16f6c9bba 100644 --- a/python/mozbuild/mozbuild/artifacts.py +++ b/python/mozbuild/mozbuild/artifacts.py @@ -654,6 +654,7 @@ class MacArtifactJob(ArtifactJob): "{product}-bin", "*.dylib", "minidump-analyzer", + "nmhproxy", "pingsender", "plugin-container.app/Contents/MacOS/plugin-container", "updater.app/Contents/Frameworks/UpdateSettings.framework/UpdateSettings", diff --git a/taskcluster/ci/config.yml b/taskcluster/ci/config.yml index 8595acd76a71..6131c9a14255 100644 --- a/taskcluster/ci/config.yml +++ b/taskcluster/ci/config.yml @@ -799,6 +799,7 @@ mac-signing: - "/Contents/MacOS/XUL" - "/Contents/MacOS/pingsender" - "/Contents/MacOS/minidump-analyzer" + - "/Contents/MacOS/nmhproxy" - "/Contents/MacOS/*.dylib" - "/Contents/Resources/gmp-clearkey/*/*.dylib" - "/Contents/Frameworks/ChannelPrefs.framework" @@ -842,6 +843,7 @@ mac-signing: - "/Contents/Library/LaunchServices/org.mozilla.updater" - "/Contents/MacOS/pingsender" - "/Contents/MacOS/minidump-analyzer" + - "/Contents/MacOS/nmhproxy" - "/Contents/Frameworks/ChannelPrefs.framework" - deep: false diff --git a/toolkit/library/rust/moz.build b/toolkit/library/rust/moz.build index eb2db8f81b07..09354102684e 100644 --- a/toolkit/library/rust/moz.build +++ b/toolkit/library/rust/moz.build @@ -31,6 +31,9 @@ RUST_TESTS = [ "gkrust", ] +if CONFIG["TARGET_OS"] in ("WINNT", "OSX"): + RUST_TESTS += ["nmhproxy"] + # Code coverage builds link a bunch of Gecko bindings code from the style # crate, which is not used by our tests but would cause link errors. #