From 730b888e39b08d125f026d8ce84447afebf754dd Mon Sep 17 00:00:00 2001 From: Yash Saraf Date: Mon, 1 Jun 2026 22:46:17 +0530 Subject: [PATCH 1/2] 1.5.0: arm64 + dynamic CloudFront source URL - New FetchDownloadSourceUrl module POSTs to /binary/api/v1/endpoint to discover the current CDN base URL (CloudFront primary; CloudFlare requested as fallback via X-Local-Fallback-Cloudflare header on retry). - Linux arm64 host now downloads BrowserStackLocal-linux-arm64. Branch order mirrors Node SDK: arm64 wins over alpine on musl. - proxyHost/proxyPort from Local#start now also flow into the binary download (previously ignored for download, used only for running binary). - User-Agent: browserstack-local-ruby/ on endpoint POST + GET. - TLS verification now enforced (VERIFY_PEER); was VERIFY_NONE. CHANGELOG calls this out for users on broken trust stores. Tracks LOC-6563. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 14 +- browserstack-local.gemspec | 15 +- lib/browserstack/fetch_download_source_url.rb | 61 ++++++ lib/browserstack/local.rb | 6 +- lib/browserstack/localbinary.rb | 189 +++++++++++------- lib/browserstack/version.rb | 3 + test/browserstack-local-test.rb | 70 +++++++ 7 files changed, 278 insertions(+), 80 deletions(-) create mode 100644 lib/browserstack/fetch_download_source_url.rb create mode 100644 lib/browserstack/version.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 109f97d..c0be9ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] - yyyy-mm-dd -## [ -1.4.3] - 2023-08-24 +## [1.5.0] - 2026-06-01 + +### Added +- Linux arm64 support — downloads `BrowserStackLocal-linux-arm64` on aarch64/arm64 hosts. +- Dynamic binary source URL via `POST local.browserstack.com/binary/api/v1/endpoint`. Primary downloads now come from CloudFront; CloudFlare is used as fallback after repeated failures. +- Proxy passthrough for binary download — `proxyHost` and `proxyPort` passed to `Local#start` are now also used when downloading the binary itself. +- `User-Agent: browserstack-local-ruby/` header on the endpoint POST and on the binary download GET. + +### Changed +- TLS certificate verification is now enforced (`OpenSSL::SSL::VERIFY_PEER`) on all binary-download HTTPS traffic. Previous releases used `VERIFY_NONE`. If you were relying on disabled verification (e.g., behind a MITM proxy without the corporate CA installed in your system trust store), pin to 1.4.3 and open an issue. + +## [1.4.3] - 2023-08-24 ### Changed Ruby 3 exists? deprecation fix diff --git a/browserstack-local.gemspec b/browserstack-local.gemspec index 9d1a650..435e1f7 100644 --- a/browserstack-local.gemspec +++ b/browserstack-local.gemspec @@ -1,14 +1,21 @@ +require File.expand_path('../lib/browserstack/version', __FILE__) + Gem::Specification.new do |s| s.name = 'browserstack-local' - s.version = '1.4.3' - s.date = '2023-08-24' + s.version = BrowserStack::VERSION + s.date = '2026-06-01' s.summary = "BrowserStack Local" s.description = "Ruby bindings for BrowserStack Local" s.authors = ["BrowserStack"] s.email = 'support@browserstack.com' - s.files = ["lib/browserstack/local.rb", "lib/browserstack/localbinary.rb", "lib/browserstack/localexception.rb"] + s.files = [ + "lib/browserstack/local.rb", + "lib/browserstack/localbinary.rb", + "lib/browserstack/localexception.rb", + "lib/browserstack/fetch_download_source_url.rb", + "lib/browserstack/version.rb" + ] s.homepage = 'http://rubygems.org/gems/browserstack-local' s.license = 'MIT' end - diff --git a/lib/browserstack/fetch_download_source_url.rb b/lib/browserstack/fetch_download_source_url.rb new file mode 100644 index 0000000..0310db4 --- /dev/null +++ b/lib/browserstack/fetch_download_source_url.rb @@ -0,0 +1,61 @@ +require 'net/http' +require 'net/https' +require 'json' +require 'openssl' +require 'browserstack/localexception' + +module BrowserStack + module FetchDownloadSourceUrl + BS_HOST = 'local.browserstack.com'.freeze + ENDPOINT_PATH = '/binary/api/v1/endpoint'.freeze + + def self.call(auth_token:, user_agent:, fallback: false, error_message: nil, + proxy_host: nil, proxy_port: nil) + uri = URI::HTTPS.build(host: BS_HOST, path: ENDPOINT_PATH) + + body = { 'auth_token' => auth_token } + body['error_message'] = error_message if fallback && error_message + + http_class = if proxy_host && proxy_port + Net::HTTP::Proxy(proxy_host, proxy_port.to_i) + else + Net::HTTP + end + + http = http_class.new(uri.host, uri.port) + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + + req = Net::HTTP::Post.new(uri.request_uri) + req['Content-Type'] = 'application/json' + req['User-Agent'] = user_agent + req['X-Local-Fallback-Cloudflare'] = 'true' if fallback + req.body = JSON.dump(body) + + res = http.request(req) + + begin + parsed = JSON.parse(res.body.to_s) + rescue JSON::ParserError => e + raise BrowserStack::LocalException.new( + "Failed to parse binary endpoint API response (HTTP #{res.code}): #{e.message}" + ) + end + + if parsed.is_a?(Hash) && parsed['error'] + raise BrowserStack::LocalException.new( + "Binary endpoint API returned error: #{parsed['error']}" + ) + end + + endpoint = parsed.is_a?(Hash) ? parsed.dig('data', 'endpoint') : nil + if endpoint.nil? || endpoint.to_s.empty? + raise BrowserStack::LocalException.new( + "Binary endpoint API returned no endpoint (HTTP #{res.code})" + ) + end + + endpoint + end + end +end diff --git a/lib/browserstack/local.rb b/lib/browserstack/local.rb index 889a024..0a01b1c 100644 --- a/lib/browserstack/local.rb +++ b/lib/browserstack/local.rb @@ -64,7 +64,11 @@ def start(options = {}) end @binary_path = if @binary_path.nil? - BrowserStack::LocalBinary.new.binary_path + BrowserStack::LocalBinary.new( + auth_token: @key, + proxy_host: @proxy_host, + proxy_port: @proxy_port + ).binary_path else @binary_path end diff --git a/lib/browserstack/localbinary.rb b/lib/browserstack/localbinary.rb index cab3f92..65e53f6 100644 --- a/lib/browserstack/localbinary.rb +++ b/lib/browserstack/localbinary.rb @@ -3,28 +3,27 @@ require 'rbconfig' require 'openssl' require 'tmpdir' +require 'fileutils' require 'browserstack/localexception' +require 'browserstack/fetch_download_source_url' +require 'browserstack/version' module BrowserStack - + class LocalBinary - def initialize - host_os = RbConfig::CONFIG['host_os'] - @http_path = case host_os - when /mswin|msys|mingw|cygwin|bccwin|wince|emc/ - @windows = true - "https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal.exe" - when /darwin|mac os/ - "https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal-darwin-x64" - when /linux-musl/ - "https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal-alpine" - when /linux/ - if 1.size == 8 - "https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal-linux-x64" - else - "https://www.browserstack.com/local-testing/downloads/binaries/BrowserStackLocal-linux-ia32" - end - end + BASE_RETRIES = 9 + FALLBACK_TRIGGER_RETRY = 4 + + def initialize(conf = {}) + @auth_token = conf[:auth_token] || ENV['BROWSERSTACK_ACCESS_KEY'] + @proxy_host = conf[:proxy_host] + @proxy_port = conf[:proxy_port] + @user_agent = conf[:user_agent] || "browserstack-local-ruby/#{BrowserStack::VERSION}" + + @windows = !!(RbConfig::CONFIG['host_os'] =~ /mswin|msys|mingw|cygwin|bccwin|wince|emc/) + @binary_filename = compute_binary_filename + @source_url = nil + @download_error_message = nil @ordered_paths = [ File.join(File.expand_path('~'), '.browserstack'), @@ -33,79 +32,123 @@ def initialize ] end - def download(dest_parent_dir) - unless File.exist? dest_parent_dir - Dir.mkdir dest_parent_dir - end - uri = URI.parse(@http_path) - binary_path = File.join(dest_parent_dir, "BrowserStackLocal#{".exe" if @windows}") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.verify_mode = OpenSSL::SSL::VERIFY_NONE - - res = http.get(uri.path) - file = open(binary_path, 'wb') - file.write(res.body) - file.close - FileUtils.chmod 0755, binary_path - - binary_path - end + def binary_path + dest_parent_dir = get_available_dirs + bin_path = File.join(dest_parent_dir, dest_binary_name) - def verify_binary(binary_path) - binary_response = IO.popen(binary_path + " --version").readline - binary_response =~ /BrowserStack Local version \d+\.\d+/ - rescue Exception => e - false + return bin_path if File.exist?(bin_path) && verify_binary(bin_path) + + File.delete(bin_path) if File.exist?(bin_path) + download_with_retries(bin_path) end - def binary_path - dest_parent_dir = get_available_dirs - binary_path = File.join(dest_parent_dir, "BrowserStackLocal#{".exe" if @windows}") + private - if File.exist? binary_path - binary_path + def dest_binary_name + @windows ? 'BrowserStackLocal.exe' : 'BrowserStackLocal' + end + + def compute_binary_filename + host_os = RbConfig::CONFIG['host_os'] + host_cpu = RbConfig::CONFIG['host_cpu'] + case host_os + when /mswin|msys|mingw|cygwin|bccwin|wince|emc/ + 'BrowserStackLocal.exe' + when /darwin|mac os/ + 'BrowserStackLocal-darwin-x64' + when /linux/ + if host_cpu =~ /arm64|aarch64/ + 'BrowserStackLocal-linux-arm64' + elsif host_os =~ /linux-musl/ + 'BrowserStackLocal-alpine' + elsif 1.size == 8 + 'BrowserStackLocal-linux-x64' + else + 'BrowserStackLocal-linux-ia32' + end else - binary_path = download(dest_parent_dir) + raise BrowserStack::LocalException.new("Unsupported host OS: #{host_os}") end + end - valid_binary = verify_binary(binary_path) - - if valid_binary - binary_path - else - binary_path = download(dest_parent_dir) - valid_binary = verify_binary(binary_path) - if valid_binary - binary_path - else - raise BrowserStack::LocalException.new('BrowserStack Local binary is corrupt') + def download_with_retries(bin_path) + retries = BASE_RETRIES + while retries > 0 + refresh_source_url(retries) if retries == BASE_RETRIES || retries == FALLBACK_TRIGGER_RETRY + begin + download_to(@source_url + '/' + @binary_filename, bin_path) + return bin_path if verify_binary(bin_path) + @download_error_message = 'Downloaded binary failed verification' + rescue StandardError => e + @download_error_message = "Download failed: #{e.message}" end + File.delete(bin_path) if File.exist?(bin_path) + retries -= 1 end + + raise BrowserStack::LocalException.new( + "Failed to download BrowserStack Local binary after #{BASE_RETRIES} attempts. " \ + "Last error: #{@download_error_message}" + ) end - private + def refresh_source_url(retries) + is_fallback = (retries == FALLBACK_TRIGGER_RETRY) && !@download_error_message.nil? + begin + @source_url = BrowserStack::FetchDownloadSourceUrl.call( + auth_token: @auth_token, + user_agent: @user_agent, + fallback: is_fallback, + error_message: @download_error_message, + proxy_host: @proxy_host, + proxy_port: @proxy_port + ) + rescue StandardError => e + raise if @source_url.nil? + @download_error_message = "Source URL refresh failed: #{e.message}" + end + end + + def download_to(url, bin_path) + uri = URI.parse(url) + http_class = if @proxy_host && @proxy_port + Net::HTTP::Proxy(@proxy_host, @proxy_port.to_i) + else + Net::HTTP + end + http = http_class.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == 'https') + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + + req = Net::HTTP::Get.new(uri.request_uri) + req['User-Agent'] = @user_agent + + res = http.request(req) + raise "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess) + + File.open(bin_path, 'wb') { |f| f.write(res.body) } + FileUtils.chmod 0755, bin_path + end + + def verify_binary(bin_path) + binary_response = IO.popen(bin_path + " --version").readline + !!(binary_response =~ /BrowserStack Local version \d+\.\d+/) + rescue StandardError + false + end def get_available_dirs - i = 0 - while i < @ordered_paths.size - path = @ordered_paths[i] - if make_path(path) - return path - else - i += 1 - end + @ordered_paths.each do |path| + return path if make_path(path) end raise BrowserStack::LocalException.new('Error trying to download BrowserStack Local binary') end def make_path(path) - begin - FileUtils.mkdir_p path if !File.directory?(path) - return true - rescue Exception - return false - end + FileUtils.mkdir_p(path) unless File.directory?(path) + true + rescue StandardError + false end end diff --git a/lib/browserstack/version.rb b/lib/browserstack/version.rb new file mode 100644 index 0000000..64a24b1 --- /dev/null +++ b/lib/browserstack/version.rb @@ -0,0 +1,3 @@ +module BrowserStack + VERSION = '1.5.0'.freeze +end diff --git a/test/browserstack-local-test.rb b/test/browserstack-local-test.rb index 00efef8..2c6218b 100644 --- a/test/browserstack-local-test.rb +++ b/test/browserstack-local-test.rb @@ -100,3 +100,73 @@ def teardown @bs_local.stop end end + +class BrowserStackLocalBinaryTest < Minitest::Test + def test_default_user_agent_contains_gem_name_and_version + ua = BrowserStack::LocalBinary.new(auth_token: 'fake').instance_variable_get(:@user_agent) + assert_match(/^browserstack-local-ruby\/#{Regexp.escape(BrowserStack::VERSION)}$/, ua) + end + + def test_custom_user_agent_respected + ua = BrowserStack::LocalBinary.new(auth_token: 'fake', user_agent: 'custom/1.0').instance_variable_get(:@user_agent) + assert_equal 'custom/1.0', ua + end + + def test_linux_arm64_picks_arm64_binary + with_host_config('linux-gnu', 'aarch64') do + assert_equal 'BrowserStackLocal-linux-arm64', + BrowserStack::LocalBinary.new.send(:compute_binary_filename) + end + end + + def test_linux_arm64_alt_cpu_name_picks_arm64_binary + with_host_config('linux-gnu', 'arm64') do + assert_equal 'BrowserStackLocal-linux-arm64', + BrowserStack::LocalBinary.new.send(:compute_binary_filename) + end + end + + def test_alpine_arm64_picks_arm64_not_alpine + # Matches Node SDK: arm64 wins over musl on Linux + with_host_config('linux-musl', 'aarch64') do + assert_equal 'BrowserStackLocal-linux-arm64', + BrowserStack::LocalBinary.new.send(:compute_binary_filename) + end + end + + def test_alpine_x64_picks_alpine_binary + with_host_config('linux-musl', 'x86_64') do + assert_equal 'BrowserStackLocal-alpine', + BrowserStack::LocalBinary.new.send(:compute_binary_filename) + end + end + + def test_darwin_arm64_picks_darwin_x64 + # No darwin-arm64 binary; runs under Rosetta. Matches Node. + with_host_config('darwin22', 'arm64') do + assert_equal 'BrowserStackLocal-darwin-x64', + BrowserStack::LocalBinary.new.send(:compute_binary_filename) + end + end + + def test_local_binary_accepts_proxy_conf + bin = BrowserStack::LocalBinary.new( + auth_token: 'fake', + proxy_host: 'proxy.example.com', + proxy_port: 8080 + ) + assert_equal 'proxy.example.com', bin.instance_variable_get(:@proxy_host) + assert_equal 8080, bin.instance_variable_get(:@proxy_port) + end + + private + + def with_host_config(host_os, host_cpu) + orig = RbConfig::CONFIG.dup + RbConfig::CONFIG['host_os'] = host_os + RbConfig::CONFIG['host_cpu'] = host_cpu + yield + ensure + RbConfig::CONFIG.replace(orig) + end +end From 51d8136c77b8ab8b84d0de452edd59b6772ce683 Mon Sep 17 00:00:00 2001 From: Yash Saraf Date: Mon, 1 Jun 2026 23:09:11 +0530 Subject: [PATCH 2/2] Add HTTP timeouts on endpoint POST and binary GET Net::HTTP defaults to 60s open + 60s read. A hung local.browserstack.com or CDN can stall the binary download for multiple minutes before the user sees an error. - fetch_download_source_url.rb: open_timeout=10, read_timeout=15 (small JSON response, fast) - localbinary.rb download_to: open_timeout=10, read_timeout=30 (read_timeout bounds per-read stalls, not total transfer) Code review finding from LOC-6563 review. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/browserstack/fetch_download_source_url.rb | 2 ++ lib/browserstack/localbinary.rb | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/browserstack/fetch_download_source_url.rb b/lib/browserstack/fetch_download_source_url.rb index 0310db4..34fbde0 100644 --- a/lib/browserstack/fetch_download_source_url.rb +++ b/lib/browserstack/fetch_download_source_url.rb @@ -25,6 +25,8 @@ def self.call(auth_token:, user_agent:, fallback: false, error_message: nil, http = http_class.new(uri.host, uri.port) http.use_ssl = true http.verify_mode = OpenSSL::SSL::VERIFY_PEER + http.open_timeout = 10 + http.read_timeout = 15 req = Net::HTTP::Post.new(uri.request_uri) req['Content-Type'] = 'application/json' diff --git a/lib/browserstack/localbinary.rb b/lib/browserstack/localbinary.rb index 65e53f6..737c590 100644 --- a/lib/browserstack/localbinary.rb +++ b/lib/browserstack/localbinary.rb @@ -119,6 +119,8 @@ def download_to(url, bin_path) http = http_class.new(uri.host, uri.port) http.use_ssl = (uri.scheme == 'https') http.verify_mode = OpenSSL::SSL::VERIFY_PEER + http.open_timeout = 10 + http.read_timeout = 30 req = Net::HTTP::Get.new(uri.request_uri) req['User-Agent'] = @user_agent