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
4 changes: 4 additions & 0 deletions core/Slopterm.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
<PackageReference Include="Cronos" Version="0.13.0" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Slopterm.Tests" />
</ItemGroup>

<ItemGroup>
<Content Remove="wwwroot/**" />
<EmbeddedResource Include="wwwroot/**" />
Expand Down
79 changes: 55 additions & 24 deletions core/UpdateService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Buffers.Binary;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Runtime.InteropServices;
Expand Down Expand Up @@ -41,6 +42,13 @@ public sealed record UpdateProgress(string Phase, double Percent, string? Error
public sealed class UpdateService
{
private const string Repo = "gwdevhub/slopterm";
private static readonly byte[] BundleHeaderSignature =
[
0x8b, 0x12, 0x02, 0xb9, 0x6a, 0x61, 0x20, 0x38,
0x72, 0x7b, 0x93, 0x02, 0x14, 0xd7, 0xa0, 0x32,
0x13, 0xf5, 0xb9, 0xe6, 0xef, 0xae, 0x33, 0x18,
0xee, 0x3b, 0x2d, 0xce, 0x24, 0xb3, 0x6a, 0xae
];
private static readonly HttpClient Http = new();

private string? _cachedCurrentSha256;
Expand Down Expand Up @@ -223,34 +231,57 @@ private static string AssetNameForCurrentPlatform()
return null;
}

// Only a genuine published single-file build is updatable - there's no single exe
// that *is* the app to hash/swap otherwise. Two independent guards, because neither
// alone is sufficient:
//
// - Empty Assembly.Location is the reliable single-file signal. When the app is
// bundled into one self-contained exe, the runtime loads assemblies straight from
// the bundle rather than from disk, so GetEntryAssembly().Location is an empty
// string. Under `dotnet run`, `dotnet build`, or any normal framework-dependent
// run it's the real on-disk DLL path instead. This is the documented way to detect
// a single-file publish at runtime. In a real single-file build ProcessPath is the
// single-file exe itself, so we still return it (the real update path is preserved).
//
// - The filename == "dotnet" guard alone is NOT enough. `dotnet run` does not leave
// us running under the shared `dotnet` host: it builds and launches the project's
// own apphost executable (Slopterm.Server.exe in bin/Debug/net10.0/), so
// ProcessPath is that apphost and its filename is "Slopterm.Server", never
// "dotnet". Relying on the filename check alone would wrongly treat a dev build as
// publishable, hash the apphost, find its SHA differs from the release asset, and
// report a bogus "update available" (plus the update dot). We keep the "dotnet"
// check as cheap belt-and-suspenders (covers `dotnet exec` on non-Windows).
var isSingleFile = string.IsNullOrEmpty(System.Reflection.Assembly.GetEntryAssembly()?.Location);
if (!isSingleFile)
var fileName = Path.GetFileNameWithoutExtension(path);
if (string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase))
{
return null;
}

var fileName = Path.GetFileNameWithoutExtension(path);
return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) ? null : path;
// IncludeAllContentForSelfExtract extracts managed assemblies before startup, so
// Assembly.Location is non-empty even for our genuine single-file releases. Inspect
// the apphost's bundle marker instead; an ordinary apphost has the same marker with a
// zero header offset, while dotnet publish fills in the real bundle header offset.
return IsSingleFileBundle(path) ? path : null;
}

internal static bool IsSingleFileBundle(string path)
{
const int bufferSize = 64 * 1024;
var overlap = BundleHeaderSignature.Length + sizeof(long) - 1;
var buffer = new byte[bufferSize + overlap];
var preserved = 0;

using var stream = File.OpenRead(path);
while (true)
{
var read = stream.Read(buffer, preserved, bufferSize);
if (read == 0)
{
return false;
}

var bytes = buffer.AsSpan(0, preserved + read);
var searchOffset = 0;
while (searchOffset < bytes.Length)
{
var relativeIndex = bytes[searchOffset..].IndexOf(BundleHeaderSignature);
if (relativeIndex < 0)
{
break;
}

var signatureIndex = searchOffset + relativeIndex;
if (signatureIndex >= sizeof(long))
{
return BinaryPrimitives.ReadInt64LittleEndian(bytes[(signatureIndex - sizeof(long))..]) != 0;
}

searchOffset = signatureIndex + 1;
}

preserved = Math.Min(overlap, bytes.Length);
bytes[^preserved..].CopyTo(buffer);
}
}

private string? ComputeCurrentExeSha256()
Expand Down
54 changes: 54 additions & 0 deletions tests/UpdateServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Buffers.Binary;
using Slopterm.Server;
using Xunit;

namespace Slopterm.Tests;

public sealed class UpdateServiceTests : IDisposable
{
private static readonly byte[] BundleHeaderSignature =
[
0x8b, 0x12, 0x02, 0xb9, 0x6a, 0x61, 0x20, 0x38,
0x72, 0x7b, 0x93, 0x02, 0x14, 0xd7, 0xa0, 0x32,
0x13, 0xf5, 0xb9, 0xe6, 0xef, 0xae, 0x33, 0x18,
0xee, 0x3b, 0x2d, 0xce, 0x24, 0xb3, 0x6a, 0xae
];

private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"slopterm-update-tests-{Guid.NewGuid():N}");

[Fact]
public void IsSingleFileBundle_RejectsOrdinaryAppHostPlaceholder()
{
var path = WriteAppHost(headerOffset: 0);

Assert.False(UpdateService.IsSingleFileBundle(path));
}

[Fact]
public void IsSingleFileBundle_AcceptsPopulatedBundleHeaderAcrossBufferBoundary()
{
var path = WriteAppHost(headerOffset: 123456, signatureOffset: (64 * 1024) - 10);

Assert.True(UpdateService.IsSingleFileBundle(path));
}

private string WriteAppHost(long headerOffset, int signatureOffset = 128)
{
Directory.CreateDirectory(_tempDirectory);
var bytes = new byte[signatureOffset + BundleHeaderSignature.Length + 16];
BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(signatureOffset - sizeof(long)), headerOffset);
BundleHeaderSignature.CopyTo(bytes, signatureOffset);

var path = Path.Combine(_tempDirectory, Guid.NewGuid().ToString("N"));
File.WriteAllBytes(path, bytes);
return path;
}

public void Dispose()
{
if (Directory.Exists(_tempDirectory))
{
Directory.Delete(_tempDirectory, recursive: true);
}
}
}
Loading