Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 /<binaryName> 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 <path>"`) the payload below runs `touch <marker>`
Expand Down Expand Up @@ -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);
}
}
}
}
12 changes: 12 additions & 0 deletions BrowserStackLocal/BrowserStackLocal Unit Tests/LocalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<RootNamespace>BrowserStack</RootNamespace>
<AssemblyName>BrowserStackLocal</AssemblyName>
<TargetFramework>net6.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<Title>BrowserStackLocal</Title>
<Product>BrowserStackLocal</Product>
<Description>C# Bindings for BrowserStack Local</Description>
Expand Down
38 changes: 33 additions & 5 deletions BrowserStackLocal/BrowserStackLocal/BrowserStackTunnel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading