From fb1e5972fc9ace9f572e25de7592e6dcb7ec87d7 Mon Sep 17 00:00:00 2001 From: 07souravkunda Date: Mon, 26 Oct 2026 10:51:37 +0530 Subject: [PATCH] LOC-7549: decode gzip binary downloads and report the real binding version downloadBinary used WebClient.DownloadFile, which never decodes Content-Encoding. The binary download endpoint is moving gzip-capable bindings to binaries/release/latest_gzip, whose objects are stored with Content-Encoding: gzip and served gzip regardless of Accept-Encoding, so this binding would have saved the gzip stream as the binary. It now downloads with HttpClient + AutomaticDecompression (GZip | Deflate), streams to disk, honours the same proxy, and deletes a partial file if the download fails. GenerateAssemblyInfo was false (since the 2020 SDK-style csproj move) and the library has no AssemblyInfo, so the csproj never reached the DLL: every release reported User-Agent browserstack-local-csharp/0.0.0000.0000 (5,462 of 5,464 C# endpoint calls in the 7 days to 2026-10-26). Generating it lets local-service tell this release, which decodes gzip, from older ones that must keep the uncompressed path. Tests: - downloadBinary against a local HttpListener: gzip-encoded response saved decoded, plain response saved as-is, 404 leaves no file. - assembly version is generated and flows into the version string. Both new tests fail on the pre-change code. Also run against the staged gzip object on CloudFront: 36,317,952 B, prod SHA-256, `--version` 8.9. Co-Authored-By: Claude Opus 5.5 --- .../BrowserStackTunnelTests.cs | 135 ++++++++++++++++++ .../LocalTests.cs | 12 ++ .../BrowserStackLocal.csproj | 2 +- .../BrowserStackLocal/BrowserStackTunnel.cs | 38 ++++- 4 files changed, 181 insertions(+), 6 deletions(-) diff --git a/BrowserStackLocal/BrowserStackLocal Unit Tests/BrowserStackTunnelTests.cs b/BrowserStackLocal/BrowserStackLocal Unit Tests/BrowserStackTunnelTests.cs index 482a347..66c196b 100644 --- a/BrowserStackLocal/BrowserStackLocal Unit Tests/BrowserStackTunnelTests.cs +++ b/BrowserStackLocal/BrowserStackLocal Unit Tests/BrowserStackTunnelTests.cs @@ -132,6 +132,135 @@ public void testFallbackException() tunnel.fallbackPaths(); } + // LOC-7549: the endpoint can hand out a path whose objects are stored with + // Content-Encoding: gzip. The saved binary must be the decoded bytes, not the gzip stream. + [TestMethod] + public void TestDownloadBinaryDecodesGzipContentEncoding() + { + byte[] binary = SampleBinary(); + byte[] saved = DownloadFrom(binary, gzip: true); + CollectionAssert.AreEqual(binary, saved, "gzip-encoded download was saved without decoding"); + } + + [TestMethod] + public void TestDownloadBinarySavesUncompressedResponseAsIs() + { + byte[] binary = SampleBinary(); + CollectionAssert.AreEqual(binary, DownloadFrom(binary, gzip: false)); + } + + [TestMethod] + public void TestDownloadBinaryFailureLeavesNoPartialFile() + { + string work = Path.Combine(Path.GetTempPath(), "bsloc" + Guid.NewGuid().ToString("N")); + try + { + using (var server = new BinaryServer(null, gzip: false)) + { + tunnel = new TunnelClass(); + tunnel.setSourceUrl(server.BaseUrl); + tunnel.setBinaryAbsolute(Path.Combine(work, binaryName)); + Assert.That(() => tunnel.downloadBinary(), Throws.Exception); + Assert.IsFalse(File.Exists(Path.Combine(work, binaryName)), "failed download left a file behind"); + } + } + finally + { + try { Directory.Delete(work, true); } catch { } + } + } + + private static byte[] SampleBinary() + { + // Compressible, like a real binary, and large enough to span several reads. + var bytes = new byte[2 * 1024 * 1024]; + for (int i = 0; i < bytes.Length; i++) bytes[i] = (byte)((i * 31) % 251); + return bytes; + } + + private byte[] DownloadFrom(byte[] binary, bool gzip) + { + string work = Path.Combine(Path.GetTempPath(), "bsloc" + Guid.NewGuid().ToString("N")); + try + { + using (var server = new BinaryServer(binary, gzip)) + { + tunnel = new TunnelClass(); + tunnel.setSourceUrl(server.BaseUrl); + tunnel.setBinaryAbsolute(Path.Combine(work, binaryName)); + tunnel.downloadBinary(); + Assert.AreEqual("/" + binaryName, server.RequestedPath); + return File.ReadAllBytes(Path.Combine(work, binaryName)); + } + } + finally + { + try { Directory.Delete(work, true); } catch { } + } + } + + // Serves `binary` at / for one request, gzip-encoded when asked to, + // the way S3/CloudFront serve an object stored with Content-Encoding: gzip. + // A null binary answers 404. + private sealed class BinaryServer : IDisposable + { + private readonly System.Net.HttpListener listener = new System.Net.HttpListener(); + private readonly System.Threading.Tasks.Task serving; + public string BaseUrl { get; } + public string RequestedPath { get; private set; } + + public BinaryServer(byte[] binary, bool gzip) + { + int port = FreePort(); + BaseUrl = "http://127.0.0.1:" + port; + listener.Prefixes.Add(BaseUrl + "/"); + listener.Start(); + serving = System.Threading.Tasks.Task.Run(() => + { + var context = listener.GetContext(); + RequestedPath = context.Request.Url.AbsolutePath; + var response = context.Response; + if (binary == null) + { + response.StatusCode = 404; + response.Close(); + return; + } + byte[] body = binary; + if (gzip) + { + using (var buffer = new MemoryStream()) + { + using (var gz = new System.IO.Compression.GZipStream(buffer, System.IO.Compression.CompressionLevel.Optimal)) + { + gz.Write(binary, 0, binary.Length); + } + body = buffer.ToArray(); + } + response.AddHeader("Content-Encoding", "gzip"); + } + response.ContentLength64 = body.Length; + response.OutputStream.Write(body, 0, body.Length); + response.Close(); + }); + } + + private static int FreePort() + { + var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + probe.Start(); + int port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + public void Dispose() + { + serving.Wait(TimeSpan.FromSeconds(10)); + listener.Close(); + } + } + // Regression for the chmod shell-metacharacter injection (F-001): binaryAbsolute must // reach chmod as a single argument, never interpolated into a shell command line. On // pre-fix code (`bash -c "chmod 0755 "`) the payload below runs `touch ` @@ -215,6 +344,12 @@ public void setBinaryAbsolute(string path) { binaryAbsolute = path; } + public void setSourceUrl(string url) + { + typeof(BrowserStackTunnel) + .GetField("sourceUrl", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + .SetValue(this, url); + } } } } diff --git a/BrowserStackLocal/BrowserStackLocal Unit Tests/LocalTests.cs b/BrowserStackLocal/BrowserStackLocal Unit Tests/LocalTests.cs index e12f893..e511814 100644 --- a/BrowserStackLocal/BrowserStackLocal Unit Tests/LocalTests.cs +++ b/BrowserStackLocal/BrowserStackLocal Unit Tests/LocalTests.cs @@ -30,6 +30,18 @@ public void setTunnel(BrowserStackTunnel tunnel) } } + // The User-Agent carries this version, and local-service uses it to tell a binding + // that decodes gzip downloads from older ones. With GenerateAssemblyInfo=false the + // csproj version never reached the assembly, so every release reported 0.0.0000.0000. + [TestMethod] + public void TestBindingVersionComesFromTheAssembly() + { + Version expected = typeof(Local).Assembly.GetName().Version; + Assert.AreNotEqual(new Version(0, 0, 0, 0), expected, "assembly version was not generated from the csproj"); + Assert.AreEqual(Local.GetVersionString(expected.ToString()), + Local.GetVersionStringFromAssemblyEmbedded("BrowserStackLocal")); + } + [TestMethod] public void TestThrowsWithNoAccessKey() { diff --git a/BrowserStackLocal/BrowserStackLocal/BrowserStackLocal.csproj b/BrowserStackLocal/BrowserStackLocal/BrowserStackLocal.csproj index e8d23de..4e3d038 100644 --- a/BrowserStackLocal/BrowserStackLocal/BrowserStackLocal.csproj +++ b/BrowserStackLocal/BrowserStackLocal/BrowserStackLocal.csproj @@ -3,7 +3,7 @@ BrowserStack BrowserStackLocal net6.0 - false + true BrowserStackLocal BrowserStackLocal C# Bindings for BrowserStack Local diff --git a/BrowserStackLocal/BrowserStackLocal/BrowserStackTunnel.cs b/BrowserStackLocal/BrowserStackLocal/BrowserStackTunnel.cs index 581f375..57480ad 100644 --- a/BrowserStackLocal/BrowserStackLocal/BrowserStackTunnel.cs +++ b/BrowserStackLocal/BrowserStackLocal/BrowserStackTunnel.cs @@ -246,14 +246,42 @@ public void downloadBinary() Directory.CreateDirectory(binaryDirectory); - using (var client = new WebClient()) + // The download endpoint can serve the binary with Content-Encoding: gzip; WebClient + // saved those bytes undecoded, so decode it here as the other bindings do. + HttpClientHandler handler = new HttpClientHandler { - client.Headers.Add(HttpRequestHeader.UserAgent, userAgent); - if (!string.IsNullOrEmpty(proxyHost) && proxyPort > 0) + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }; + if (!string.IsNullOrEmpty(proxyHost) && proxyPort > 0) + { + handler.Proxy = new WebProxy(proxyHost, proxyPort); + handler.UseProxy = true; + } + + try + { + using (var client = new HttpClient(handler)) + { + client.DefaultRequestHeaders.Add("User-Agent", userAgent); + using (var response = client.GetAsync(sourceUrl + "/" + binaryName, HttpCompletionOption.ResponseHeadersRead).Result) + { + response.EnsureSuccessStatusCode(); + using (var body = response.Content.ReadAsStreamAsync().Result) + using (var file = File.Create(this.binaryAbsolute)) + { + body.CopyTo(file); + } + } + } + } + catch + { + // Don't leave a truncated binary behind for the next attempt to find. + if (File.Exists(binaryAbsolute)) { - client.Proxy = new WebProxy(proxyHost, proxyPort); + File.Delete(binaryAbsolute); } - client.DownloadFile(sourceUrl + "/" + binaryName, this.binaryAbsolute); + throw; } if (!File.Exists(binaryAbsolute))