Skip to content
Draft
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
24 changes: 24 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,30 @@ jobs:
$line = if ($status -eq 'Valid') { 'signed, signature checked' } else { "**unsigned** ($status)" }
Add-Content $env:GITHUB_STEP_SUMMARY "Windows installer ``$($file.Name)``: $line"
}
# The list of files (deploy/update-file-list.cs) must be signed with the same certificate as the installer,
# or every updater with a publisher policy refuses this release (docs/enterprise.md#update-publisher).
# This release's package: the folder also holds the previous release's, downloaded for the delta.
$name = "SemanticRouter-$($env:GITHUB_REF_NAME.TrimStart('v'))-win-x64-full.nupkg"
$package = @(Get-ChildItem installers/win-x64 -Filter $name)
if ($package.Count -ne 1) { Write-Output "::error::No $name in installers/win-x64."; exit 1 }
Add-Type -AssemblyName System.IO.Compression.FileSystem
$list = Join-Path $env:RUNNER_TEMP 'SemanticRouter.Files.dll'
$zip = [System.IO.Compression.ZipFile]::OpenRead($package[0].FullName)
try {
$entry = $zip.GetEntry('lib/app/SemanticRouter.Files.dll')
if ($null -eq $entry) { Write-Output "::error::$($package[0].Name) has no lib/app/SemanticRouter.Files.dll."; exit 1 }
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $list, $true)
} finally { $zip.Dispose() }
$signature = Get-AuthenticodeSignature $list
if ($env:SIGNING -eq 'signed') {
$installer = (Get-AuthenticodeSignature $setup[0].FullName).SignerCertificate.Subject
if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -ne $installer) {
Write-Output "::error::SemanticRouter.Files.dll in $($package[0].Name) should be signed by $installer, but its signature is $($signature.Status) ($($signature.SignerCertificate.Subject))."
exit 1
}
}
$line = if ($signature.Status -eq 'Valid') { 'signed, signature checked' } else { "**unsigned** ($($signature.Status))" }
Add-Content $env:GITHUB_STEP_SUMMARY "Windows update's list of files ``SemanticRouter.Files.dll``: $line"

- name: Attach
shell: bash
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ User-visible changes to SemanticRouter.NET: the NuGet packages, the `semantic-ro
- Desktop app: the app window hands only `https` and `http` links to your browser. A link or navigation to any other scheme (`file:`, a network share, `ms-msdt:`, `search-ms:`, `javascript:`, `data:` or another program's protocol) is dropped and noted in the app's log instead of being passed to the operating system, and the app acts on a message from the page only while the window shows the router. (#238)
- Desktop app: a release build ignores `SEMANTICROUTER_UPDATE_SOURCE` unless the machine policy sets the new `Updates:AllowCustomFeed`, and an update feed (from the policy or the variable) must be HTTPS or a folder; plain HTTP is accepted only to this machine, and other schemes are refused. (#238)
- Desktop app: with `Updates:Publisher` set on Windows, an update is also refused when its `*.runtimeconfig.json` or `*.deps.json` would make the .NET host load code from outside the package (`additionalProbingPaths`, `STARTUP_HOOKS` and the host's other properties, paths that leave the app's folder) or it has a `*.runtimeconfig.dev.json`. (#238)
- Desktop app: with `Updates:Publisher` set on Windows, an update's data files are checked too, not only its programs and libraries. Each Windows build lists the SHA-256 of the app's other files (`*.runtimeconfig.json`, `*.deps.json`, the notices, images) in `SemanticRouter.Files.dll`, which is signed with the release; an update is refused when that list is missing or not signed by the publisher, or when one of the app's data files was changed, added or removed after signing (`deploy/update-file-list.cs`, which the installer build also uses to check the package). Releases from before the list are refused under this policy, as they were already for being unsigned. (#181)
- On Windows, a `SEMANTIC_ROUTER_HOME` or `SEMANTIC_ROUTER_KEYS_DIR` folder the router creates outside your profile is readable only by your account, SYSTEM and Administrators, instead of inheriting permissions that usually let every user read it; `doctor` warns when an existing configuration or key ring folder can be read by `Users`, `Everyone` or `Authenticated Users`. (#238)
- The in-process (`Onnx`) embeddings no longer send usage data to Microsoft or write a device id to `~/.cache/Microsoft/DeveloperTools/.onnxruntime` on Linux and macOS, offline mode included: the router sets `ORT_DISABLE_TELEMETRY=1` in its own environment before ONNX Runtime loads (a value you set is kept), and the container image and the services `service install` writes set it too. `doctor` says whether ONNX Runtime's telemetry is off. (#238)
- Prompted tool calling (CLI connectors with `ToolCalling: Prompted`): the text of the conversation's turns can no longer pose as transcript structure. A line of an attached file or pasted page that reads `[assistant]`, `[user]` or `[/tool_result]` gets a backslash in front, as tool results already did, also when spaces or invisible characters come before it. A call in a tool result broken up with zero-width spaces or other invisible characters counts as copied from it, so a model that leaves them out makes no call. (#111)
Expand Down
3 changes: 3 additions & 0 deletions deploy/package-installer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ case "$rid" in
# A tray app: a Start menu entry, no desktop icon. No portable zip: the zip downloads (package-desktop.sh) stay.
vpk ${directive[@]+"${directive[@]}"} pack "${common[@]}" --mainExe SemanticRouter.exe --icon "$assets/icon.ico" --shortcuts StartMenuRoot --noPortable \
${signing[@]+"${signing[@]}"}
# Every file of the app that is not a program or library must be in its signed list (publish-desktop.sh), or
# an updater with a publisher policy refuses the update (docs/enterprise.md#update-publisher).
dotnet run --file "$root/deploy/update-file-list.cs" -- check "$out/SemanticRouter-$version-$rid-full.nupkg"
;;
linux-*)
vpk ${directive[@]+"${directive[@]}"} pack "${common[@]}" --mainExe SemanticRouter --icon "$assets/icon.png" --categories "Network;Development;"
Expand Down
7 changes: 7 additions & 0 deletions deploy/publish-desktop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,10 @@ dotnet publish "$root/src/SemanticRouter.Desktop/SemanticRouter.Desktop.csproj"
"$root/deploy/check-native-libraries.sh" "$rid" "$out"

dotnet run --file "$root/deploy/third-party-notices.cs" -- "$out/THIRD-PARTY-NOTICES.txt" "$out"

# Windows: SemanticRouter.Files.dll, the SHA-256 of every file that is not a program or library, which vpk signs with
# the rest so the app's publisher check covers those files too (deploy/update-file-list.cs). Last, so it lists the
# notices as well; package-installer.sh checks it against the package.
case "$rid" in
win-*) dotnet run --file "$root/deploy/update-file-list.cs" -- write "$out" ;;
esac
187 changes: 187 additions & 0 deletions deploy/update-file-list.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// The list of a Windows build's files that its signature covers. Authenticode signs programs and libraries, not the
// data files beside them (SemanticRouter.runtimeconfig.json, SemanticRouter.deps.json, THIRD-PARTY-NOTICES.txt, …), so
// `write` puts the SHA-256 of every file in a published app that is not a Windows executable image into a small library,
// SemanticRouter.Files.dll, next to them. vpk signs that library with the rest of the release, and the desktop app's
// publisher check (src/SemanticRouter.Desktop/UpdateFileList.cs) refuses an update whose data files differ from it.
// `check` reads the list back from a Velopack package and fails unless it matches the package's files exactly, so a
// file added to the app after `write` ran is caught before the release, not by every updater in the field. A .NET 10
// file-based app, with nothing to install:
//
// dotnet run --file deploy/update-file-list.cs -- write <publish-dir>
// dotnet run --file deploy/update-file-list.cs -- check <SemanticRouter-<version>-<rid>-full.nupkg>
//
// The library holds one managed resource, files.json: {"files":{"<path relative to the app folder>":"<sha256 hex>"}},
// paths with forward slashes, sorted. It is never loaded: no deps.json names it. Same files, same bytes.
#:property PublishAot=false

using System.IO.Compression;
using System.Reflection;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using System.Reflection.PortableExecutable;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;

const string ListFile = "SemanticRouter.Files.dll";
const string Resource = "files.json";
const string AppFolder = "lib/app/";

// vpk writes it into the app folder after the list was made: a copy of the package's .nuspec.
const string VersionFile = "sq.version";

if (args is ["write", var publish])
{
string root = Path.GetFullPath(publish);
var files = new SortedDictionary<string, string>(StringComparer.Ordinal);
foreach (string file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
{
string relative = Path.GetRelativePath(root, file).Replace('\\', '/');
using FileStream stream = File.OpenRead(file);
if (relative != ListFile && !IsExecutableImage(stream))
{
stream.Position = 0;
files[relative] = Convert.ToHexStringLower(SHA256.HashData(stream));
}
}

var json = new JsonObject { ["files"] = new JsonObject(files.Select(f => KeyValuePair.Create(f.Key, (JsonNode?)f.Value))) };
File.WriteAllBytes(Path.Combine(root, ListFile), Library(JsonSerializer.SerializeToUtf8Bytes(json)));
Console.WriteLine($"{ListFile}: {files.Count} files that are not programs or libraries");
return 0;
}

if (args is ["check", var package])
{
using ZipArchive zip = ZipFile.OpenRead(package);
string name = Path.GetFileName(package);
ZipArchiveEntry[] app = [.. zip.Entries.Where(e => e.FullName.StartsWith(AppFolder, StringComparison.Ordinal) && !e.FullName.EndsWith('/'))];
if (app.FirstOrDefault(e => e.FullName == AppFolder + ListFile) is not { } list)
{
Console.Error.WriteLine($"::error::{name} has no {ListFile}: run deploy/update-file-list.cs write on the published app before packaging it.");
return 1;
}

var listed = new Dictionary<string, string>(StringComparer.Ordinal);
foreach ((string path, JsonNode? hash) in JsonNode.Parse(ReadResource(Bytes(list)))!["files"]!.AsObject())
{
listed[path] = hash!.GetValue<string>();
}

byte[] nuspec = Bytes(zip.Entries.Single(e => !e.FullName.Contains('/', StringComparison.Ordinal) && e.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)));
var problems = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (ZipArchiveEntry entry in app)
{
string relative = entry.FullName[AppFolder.Length..];
byte[] content = Bytes(entry);
if (!seen.Add(relative))
{
problems.Add($"{relative} is in the package twice");
}
else if (IsExecutableImage(new MemoryStream(content)))
{
// Covered by its own signature.
}
else if (relative == VersionFile)
{
if (!content.AsSpan().SequenceEqual(nuspec))
{
problems.Add($"{VersionFile} is not a copy of the package's .nuspec");
}
}
else if (!listed.Remove(relative, out string? expected))
{
problems.Add($"{relative} is not in {ListFile}: it was added after deploy/update-file-list.cs write ran");
}
else if (!string.Equals(Convert.ToHexStringLower(SHA256.HashData(content)), expected, StringComparison.OrdinalIgnoreCase))
{
problems.Add($"{relative} was changed after deploy/update-file-list.cs write ran");
}
}

problems.AddRange(listed.Keys.Select(path => $"{path} is in {ListFile} but not in the package (or is now a program or library)"));
foreach (string problem in problems)
{
Console.Error.WriteLine($"::error::{name}: {problem}");
}

if (problems.Count > 0)
{
return 1;
}

Console.WriteLine($"{name}: every file that is not a program or library is in {ListFile}");
return 0;
}

Console.Error.WriteLine("Usage: dotnet run --file deploy/update-file-list.cs -- write <publish-dir> | check <full.nupkg>");
return 2;

// A Windows program or library starts with MZ; the desktop app's check tells them apart the same way.
static bool IsExecutableImage(Stream stream)
{
Span<byte> header = stackalloc byte[2];
return stream.ReadAtLeast(header, 2, throwOnEndOfStream: false) == 2 && header[0] == (byte)'M' && header[1] == (byte)'Z';
}

static byte[] Bytes(ZipArchiveEntry entry)
{
using Stream stream = entry.Open();
using var bytes = new MemoryStream();
stream.CopyTo(bytes);
return bytes.ToArray();
}

// A library with no code and one managed resource. Its identity comes from its contents, so the same list gives the
// same bytes.
static byte[] Library(byte[] resource)
{
var metadata = new MetadataBuilder();
metadata.AddModule(0, metadata.GetOrAddString(ListFile), metadata.GetOrAddGuid(new Guid(SHA256.HashData(resource).AsSpan(0, 16))), default, default);
metadata.AddAssembly(metadata.GetOrAddString(Path.GetFileNameWithoutExtension(ListFile)), new Version(1, 0, 0, 0), default, default, default, AssemblyHashAlgorithm.Sha1);
metadata.AddTypeDefinition(default, default, metadata.GetOrAddString("<Module>"), default, MetadataTokens.FieldDefinitionHandle(1), MetadataTokens.MethodDefinitionHandle(1));

var resources = new BlobBuilder();
metadata.AddManifestResource(ManifestResourceAttributes.Public, metadata.GetOrAddString(Resource), default, (uint)resources.Count);
resources.WriteInt32(resource.Length);
resources.WriteBytes(resource);

var builder = new ManagedPEBuilder(
PEHeaderBuilder.CreateLibraryHeader(),
new MetadataRootBuilder(metadata),
new BlobBuilder(),
managedResources: resources,
deterministicIdProvider: content =>
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (Blob blob in content)
{
ArraySegment<byte> bytes = blob.GetBytes();
hash.AppendData(bytes.Array!, bytes.Offset, bytes.Count);
}

return BlobContentId.FromHash(hash.GetHashAndReset());
});
var image = new BlobBuilder();
builder.Serialize(image);
return image.ToArray();
}

static byte[] ReadResource(byte[] library)
{
using var pe = new PEReader(new MemoryStream(library));
MetadataReader metadata = pe.GetMetadataReader();
foreach (ManifestResourceHandle handle in metadata.ManifestResources)
{
ManifestResource resource = metadata.GetManifestResource(handle);
if (resource.Implementation.IsNil && metadata.StringComparer.Equals(resource.Name, Resource))
{
BlobReader reader = pe.GetSectionData(pe.PEHeaders.CorHeader!.ResourcesDirectory.RelativeVirtualAddress).GetReader();
reader.Offset = checked((int)resource.Offset);
return reader.ReadBytes(reader.ReadInt32());
}
}

throw new InvalidDataException($"{ListFile} has no {Resource}");
}
Loading
Loading