Skip to content
Merged
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
15 changes: 15 additions & 0 deletions SqlPilot.sln
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlPilot.Package.Legacy", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlPilot.Installer", "src\SqlPilot.Installer\SqlPilot.Installer.csproj", "{5DE3DAB8-FC8F-462F-9369-A32A54D5E4F6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlPilot.Installer.Tests", "tests\SqlPilot.Installer.Tests\SqlPilot.Installer.Tests.csproj", "{277D16A8-40F7-4E2D-A953-62C4734295C4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -143,6 +145,18 @@ Global
{5DE3DAB8-FC8F-462F-9369-A32A54D5E4F6}.Release|x64.Build.0 = Release|Any CPU
{5DE3DAB8-FC8F-462F-9369-A32A54D5E4F6}.Release|x86.ActiveCfg = Release|Any CPU
{5DE3DAB8-FC8F-462F-9369-A32A54D5E4F6}.Release|x86.Build.0 = Release|Any CPU
{277D16A8-40F7-4E2D-A953-62C4734295C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{277D16A8-40F7-4E2D-A953-62C4734295C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{277D16A8-40F7-4E2D-A953-62C4734295C4}.Debug|x64.ActiveCfg = Debug|Any CPU
{277D16A8-40F7-4E2D-A953-62C4734295C4}.Debug|x64.Build.0 = Debug|Any CPU
{277D16A8-40F7-4E2D-A953-62C4734295C4}.Debug|x86.ActiveCfg = Debug|Any CPU
{277D16A8-40F7-4E2D-A953-62C4734295C4}.Debug|x86.Build.0 = Debug|Any CPU
{277D16A8-40F7-4E2D-A953-62C4734295C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{277D16A8-40F7-4E2D-A953-62C4734295C4}.Release|Any CPU.Build.0 = Release|Any CPU
{277D16A8-40F7-4E2D-A953-62C4734295C4}.Release|x64.ActiveCfg = Release|Any CPU
{277D16A8-40F7-4E2D-A953-62C4734295C4}.Release|x64.Build.0 = Release|Any CPU
{277D16A8-40F7-4E2D-A953-62C4734295C4}.Release|x86.ActiveCfg = Release|Any CPU
{277D16A8-40F7-4E2D-A953-62C4734295C4}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -157,6 +171,7 @@ Global
{B36CB175-6642-4E6A-A6A2-E8A7913404CF} = {E1F2A3B4-0001-0002-0003-000400050006}
{DB162456-E5F3-4B9E-ABD8-E17B60C8C124} = {E1F2A3B4-0001-0002-0003-000400050006}
{5DE3DAB8-FC8F-462F-9369-A32A54D5E4F6} = {E1F2A3B4-0001-0002-0003-000400050006}
{277D16A8-40F7-4E2D-A953-62C4734295C4} = {E1F2A3B4-0001-0002-0003-000400050007}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F1E2D3C4-A5B6-C7D8-E9F0-112233445566}
Expand Down
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ SqlPilot/
│ └── SqlPilot.Installer/ # Standalone WPF one-click installer (downloads from GitHub Releases)
│ # Target: net472 + WPF
├── tests/
│ └── SqlPilot.Core.Tests/ # xUnit tests for core logic
│ ├── SqlPilot.Core.Tests/ # xUnit tests for core logic
│ └── SqlPilot.Installer.Tests/# xUnit tests for the installer's release-JSON parsing
├── spike/ # Phase 0 spike (SSMS 22, VSSDK 17.x)
├── spike-legacy/ # Phase 0 spike (SSMS 18/20, VSSDK 15.x)
├── lib/Ssms18/ # Compile-time refs against SSMS 18 SMO/SqlWorkbench DLLs
Expand Down
73 changes: 59 additions & 14 deletions src/SqlPilot.Installer/Services/GitHubReleaseClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,31 +174,76 @@ private static string UnescapeJsonString(string s)

/// <summary>
/// Walks the "assets" array in the release JSON and pulls out each entry's
/// name, size, and browser_download_url. Regex-based — fragile by design,
/// but the GitHub API shape is stable enough that this is acceptable for
/// the small set of fields we need.
/// name, size, and browser_download_url.
/// </summary>
private static List<ReleaseAsset> ExtractAssets(string json)
internal static List<ReleaseAsset> ExtractAssets(string json)
{
var assets = new List<ReleaseAsset>();
// Find each {...} object inside the "assets":[...] array
var assetsArrayMatch = Regex.Match(json, "\"assets\"\\s*:\\s*\\[(.*?)\\]", RegexOptions.Singleline);
if (!assetsArrayMatch.Success) return assets;

var arrayBody = assetsArrayMatch.Groups[1].Value;
// Each asset object — match braces non-greedy
foreach (Match obj in Regex.Matches(arrayBody, "\\{(?:[^{}]|(?<o>\\{)|(?<-o>\\}))*\\}", RegexOptions.Singleline))
foreach (var obj in EnumerateArrayObjects(json, "assets"))
{
var name = ExtractJsonValue(obj.Value, "name");
var url = ExtractJsonValue(obj.Value, "browser_download_url");
var size = ExtractJsonLong(obj.Value, "size") ?? 0;
var name = ExtractJsonValue(obj, "name");
var url = ExtractJsonValue(obj, "browser_download_url");
var size = ExtractJsonLong(obj, "size") ?? 0;
if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(url))
{
assets.Add(new ReleaseAsset { Name = name, BrowserDownloadUrl = url, Size = size });
}
}
return assets;
}

/// <summary>
/// Yields each top-level object of the named JSON array, tracking depth and
/// skipping over string literals so that punctuation inside a string can't be
/// mistaken for structure.
/// </summary>
/// <remarks>
/// This has to scan rather than match: a regex ending the array at the first
/// "]" is wrong, and not theoretically so. GitHub reports the uploader of an
/// Actions-built release as "github-actions[bot]", and that bracket truncated
/// the array mid-object, so every release looked like it had no assets at all
/// and the installer refused to install anything.
/// </remarks>
private static IEnumerable<string> EnumerateArrayObjects(string json, string key)
{
var arrayStart = Regex.Match(json, $"\"{Regex.Escape(key)}\"\\s*:\\s*\\[");
if (!arrayStart.Success) yield break;

int depth = 0;
int objectStart = -1;
bool inString = false;
bool escaped = false;

for (int i = arrayStart.Index + arrayStart.Length; i < json.Length; i++)
{
char c = json[i];

if (inString)
{
if (escaped) escaped = false;
else if (c == '\\') escaped = true;
else if (c == '"') inString = false;
continue;
}

switch (c)
{
case '"':
inString = true;
break;
case '{':
if (depth++ == 0) objectStart = i;
break;
case '}':
if (--depth == 0) yield return json.Substring(objectStart, i - objectStart + 1);
break;
case ']':
// Only a "]" outside every object closes the array itself.
if (depth == 0) yield break;
break;
}
}
}
}

internal sealed class ReleaseInfo
Expand Down
8 changes: 8 additions & 0 deletions src/SqlPilot.Installer/SqlPilot.Installer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
</ItemGroup>

<!-- The GitHub release JSON parsing is internal but worth testing directly:
a silent parsing failure there means the installer downloads nothing. -->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>SqlPilot.Installer.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>

<!-- HttpClient lives in System.Net.Http on net472 — needs an explicit framework reference -->
<ItemGroup>
<Reference Include="System.Net.Http" />
Expand Down
114 changes: 114 additions & 0 deletions tests/SqlPilot.Installer.Tests/GitHubReleaseAssetTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.Linq;
using FluentAssertions;
using SqlPilot.Installer.Services;
using Xunit;

namespace SqlPilot.Installer.Tests
{
/// <summary>
/// Guards the release-asset parsing the one-click installer depends on. If this
/// returns nothing the installer can't download anything, and it fails with
/// "Release vX has no SqlPilot ZIP asset attached" — which is what shipped.
/// </summary>
public class GitHubReleaseAssetTests
{
/// <summary>
/// Shaped like a real GitHub releases payload. The load-bearing detail is the
/// uploader login "github-actions[bot]": every release built by the workflow has
/// it, and the "]" inside that string used to end the assets array early.
/// </summary>
private const string ReleaseJson = @"{
""url"": ""https://api.github.com/repos/mourier/sql-pilot/releases/1"",
""assets_url"": ""https://api.github.com/repos/mourier/sql-pilot/releases/1/assets"",
""html_url"": ""https://github.com/mourier/sql-pilot/releases/tag/v1.0.0"",
""tag_name"": ""v1.0.0"",
""assets"": [
{
""url"": ""https://api.github.com/repos/mourier/sql-pilot/releases/assets/1"",
""id"": 1,
""name"": ""SqlPilot-v1.0.0.zip"",
""label"": """",
""uploader"": { ""login"": ""github-actions[bot]"", ""id"": 41898282, ""type"": ""Bot"" },
""content_type"": ""application/zip"",
""state"": ""uploaded"",
""size"": 492544,
""browser_download_url"": ""https://github.com/mourier/sql-pilot/releases/download/v1.0.0/SqlPilot-v1.0.0.zip""
},
{
""url"": ""https://api.github.com/repos/mourier/sql-pilot/releases/assets/2"",
""id"": 2,
""name"": ""SqlPilotInstaller-v1.0.0.zip"",
""label"": """",
""uploader"": { ""login"": ""github-actions[bot]"", ""id"": 41898282, ""type"": ""Bot"" },
""content_type"": ""application/zip"",
""state"": ""uploaded"",
""size"": 376832,
""browser_download_url"": ""https://github.com/mourier/sql-pilot/releases/download/v1.0.0/SqlPilotInstaller-v1.0.0.zip""
}
],
""body"": ""Release notes [with a bracket] and a } brace.""
}";

[Fact]
public void ExtractAssets_ReturnsEveryAsset_WhenTheUploaderLoginContainsABracket()
{
var assets = GitHubReleaseClient.ExtractAssets(ReleaseJson);

assets.Select(a => a.Name).Should().Equal(
"SqlPilot-v1.0.0.zip",
"SqlPilotInstaller-v1.0.0.zip");
}

[Fact]
public void ExtractAssets_KeepsTheDownloadUrlAndSizeOfEachAsset()
{
var payload = GitHubReleaseClient.ExtractAssets(ReleaseJson)
.Single(a => a.Name == "SqlPilot-v1.0.0.zip");

payload.BrowserDownloadUrl.Should().Be(
"https://github.com/mourier/sql-pilot/releases/download/v1.0.0/SqlPilot-v1.0.0.zip");
payload.Size.Should().Be(492544);
}

[Fact]
public void ExtractAssets_FindsThePayloadZip_NotTheInstallerZipOrTheVsix()
{
// Mirrors InstallEngine's selection: the "SqlPilot-" prefix excludes the
// installer's own ZIP, and the ".zip" suffix excludes the gallery .vsix,
// which sorts first in a real v1.1.1 payload.
const string withVsix = @"{ ""tag_name"": ""v1.1.1"", ""assets"": [
{ ""name"": ""SqlPilot-v1.1.1.vsix"", ""size"": 1, ""uploader"": { ""login"": ""github-actions[bot]"" },
""browser_download_url"": ""https://example.invalid/SqlPilot-v1.1.1.vsix"" },
{ ""name"": ""SqlPilot-v1.1.1.zip"", ""size"": 2, ""uploader"": { ""login"": ""github-actions[bot]"" },
""browser_download_url"": ""https://example.invalid/SqlPilot-v1.1.1.zip"" },
{ ""name"": ""SqlPilotInstaller-v1.1.1.zip"", ""size"": 3, ""uploader"": { ""login"": ""github-actions[bot]"" },
""browser_download_url"": ""https://example.invalid/SqlPilotInstaller-v1.1.1.zip"" } ] }";

var picked = GitHubReleaseClient.ExtractAssets(withVsix)
.FirstOrDefault(a => a.Name.StartsWith("SqlPilot-") && a.Name.EndsWith(".zip"));

picked.Should().NotBeNull();
picked.Name.Should().Be("SqlPilot-v1.1.1.zip");
}

[Fact]
public void ExtractAssets_ReturnsEmpty_WhenTheReleaseHasNoAssets()
{
GitHubReleaseClient.ExtractAssets(@"{ ""tag_name"": ""v9.9.9"", ""assets"": [] }")
.Should().BeEmpty();
}

[Fact]
public void ExtractAssets_StopsAtTheEndOfTheAssetsArray()
{
// A later array of objects in the payload must not be read as more assets.
const string trailing = @"{ ""assets"": [
{ ""name"": ""SqlPilot-v1.0.0.zip"", ""size"": 1, ""uploader"": { ""login"": ""github-actions[bot]"" },
""browser_download_url"": ""https://example.invalid/a.zip"" } ],
""reactions"": [ { ""name"": ""not-an-asset.zip"", ""browser_download_url"": ""https://example.invalid/b.zip"" } ] }";

GitHubReleaseClient.ExtractAssets(trailing)
.Select(a => a.Name).Should().Equal("SqlPilot-v1.0.0.zip");
}
}
}
24 changes: 24 additions & 0 deletions tests/SqlPilot.Installer.Tests/SqlPilot.Installer.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<RootNamespace>SqlPilot.Installer.Tests</RootNamespace>
<AssemblyName>SqlPilot.Installer.Tests</AssemblyName>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\SqlPilot.Installer\SqlPilot.Installer.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- Pinned below 8.0: FluentAssertions switched to the non-commercial Xceed Community License in v8 (Jan 2025). v7.x stays Apache 2.0. -->
<PackageReference Include="FluentAssertions" Version="[7.1.0,8.0.0)" />
</ItemGroup>
</Project>
Loading