Skip to content

Commit c203860

Browse files
committed
Fix website vendor compression identity and retain real builder diagnostics
Preserve pinned vendor raw bytes and record actual runtime compression separately. Strengthen real builder rejection flows and retain complete diagnostics before browser startup. All GitHub native and browser qualification gates remain required.
1 parent 7c50573 commit c203860

11 files changed

Lines changed: 828 additions & 15 deletions

File tree

‎site/Features/BenchmarkComparisons/build-site.mjs‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ const BUILD = Object.freeze({
2525
noJekyll: '.nojekyll', cname: 'CNAME', domain: 'www.keyload.cloud\n', robotsFile: 'robots.txt', sitemapFile: 'sitemap.xml',
2626
smokeLabel: ' · correctness smoke', zero: 0, one: 1, jsonIndent: 2,
2727
authoredJsLimit: 40960, cssLimit: 20480,
28+
compression: 'compression', nodeVersion: 'nodeVersion', zlibVersion: 'zlibVersion', vendorReceipt: 'vendor',
29+
recordedGzipBytes: 'recordedGzipBytes', runtimeGzipBytes: 'runtimeGzipBytes', safeGzip: 'isSafeInteger',
30+
path: 'path', bytes: 'bytes',
2831
parent: '..', staticEvidence: '<!-- KEYLOAD_STATIC_EVIDENCE -->',
2932
documentationBlob: '/blob/main/docs', documentationTree: '/tree/main/docs',
3033
robots: 'User-agent: *\nAllow: /\nSitemap: https://www.keyload.cloud/sitemap.xml\n',
@@ -87,14 +90,23 @@ async function verifyVendor() {
8790
if (manifest.package !== BUILD.three || manifest.version !== BUILD.version || manifest.license !== BUILD.license ||
8891
manifest.sourceCommit !== BUILD.sourceCommit || manifest.integrity !== BUILD.integrity ||
8992
manifest.files?.length !== BUILD.vendorFiles.length) throw new Error(ERRORS.vendor);
93+
const vendor = [];
9094
for (const [index, name] of BUILD.vendorFiles.entries()) {
9195
const path = join(root, name);
9296
await assertRegular(path);
9397
const bytes = await readFile(path);
9498
const recorded = manifest.files.find(file => file.path === name);
95-
if (hash(bytes) !== BUILD.vendorHashes[index] || recorded?.sha256 !== BUILD.vendorHashes[index] ||
96-
recorded.bytes !== bytes.length || recorded.gzipBytes !== gzipSync(bytes).length) throw new Error(ERRORS.vendor);
99+
const sha256 = hash(bytes);
100+
const runtimeGzipBytes = gzipSync(bytes).length;
101+
if (sha256 !== BUILD.vendorHashes[index] || recorded?.sha256 !== BUILD.vendorHashes[index] ||
102+
recorded.bytes !== bytes.length || !Number[BUILD.safeGzip](recorded.gzipBytes) ||
103+
!(recorded.gzipBytes > BUILD.zero)) throw new Error(ERRORS.vendor);
104+
vendor.push({ [BUILD.path]: name, [BUILD.hash]: sha256, [BUILD.bytes]: bytes.length,
105+
[BUILD.recordedGzipBytes]: recorded.gzipBytes, [BUILD.runtimeGzipBytes]: runtimeGzipBytes });
97106
}
107+
108+
return { [BUILD.nodeVersion]: process.versions.node, [BUILD.zlibVersion]: process.versions.zlib,
109+
[BUILD.vendorReceipt]: vendor };
98110
}
99111

100112
async function verifyAssets() {
@@ -178,13 +190,13 @@ async function emitHtml(output, catalog) {
178190
export async function buildSite(argv) {
179191
const args = parseArguments(argv);
180192
const { reports, output } = await preparePaths(args);
181-
await verifyVendor();
193+
const compression = await verifyVendor();
182194
const sizes = await verifyAssets();
183195
const catalog = await readCatalog(reports, args);
184196
await mkdir(output);
185197
try {
186198
await emit(output, reports, catalog);
187-
return { output, profiles: catalog.runs.length, ...sizes };
199+
return { output, profiles: catalog.runs.length, ...sizes, [BUILD.compression]: compression };
188200
} catch (error) {
189201
await rm(output, { recursive: true, force: true });
190202
throw error;

‎tests/KeyLoad.SiteTests/Features/BenchmarkComparisons/SiteBrowserSession.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ private static async Task<SiteBrowserSession> StartOwnedResources(SiteTestInputs
4949
var build = await SiteBuilderProcess.RunAsync(inputs, inputs.Reports, startup.Output, cancellationToken);
5050
if (build.ExitCode != SiteTokens.ProcessSuccessExitCode || build.StandardError.Length != SiteTokens.Zero)
5151
{
52-
throw new InvalidOperationException(SiteBrowserTokens.BrowserStartFailure);
52+
throw new InvalidOperationException(SiteBuilderDiagnostics.PreChromeFailure(build));
5353
}
5454

5555
startup.StartHost();

‎tests/KeyLoad.SiteTests/Features/BenchmarkComparisons/SiteBuildSupport.cs‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ internal static class SiteBuilderProcess
99
public static async Task<SiteProcessResult> RunAsync(SiteTestInputs inputs, string reports,
1010
string output, CancellationToken cancellationToken, string? additionalArgument = null)
1111
{
12+
var arguments = CreateArguments(inputs, reports, output, additionalArgument);
13+
var builderSources = await SiteBuilderDiagnostics.ReadBuilderSourcesAsync(inputs, cancellationToken);
1214
using var process = new Process
1315
{
14-
StartInfo = CreateStartInfo(inputs, reports, output, additionalArgument),
16+
StartInfo = CreateStartInfo(inputs, arguments),
1517
EnableRaisingEvents = true
1618
};
1719
if (!process.Start())
@@ -31,7 +33,9 @@ public static async Task<SiteProcessResult> RunAsync(SiteTestInputs inputs, stri
3133
await exitTask;
3234
var stdout = await stdoutTask;
3335
var stderr = await stderrTask;
34-
return new(process.ExitCode, stdout, stderr);
36+
var result = new SiteProcessResult(process.ExitCode, stdout, stderr);
37+
await SiteBuilderDiagnostics.RetainAsync(inputs, arguments, builderSources, result, cancellationToken);
38+
return result;
3539
}
3640
catch (Exception)
3741
{
@@ -48,8 +52,26 @@ public static async Task<SiteProcessResult> RunAsync(SiteTestInputs inputs, stri
4852
}
4953
}
5054

51-
private static ProcessStartInfo CreateStartInfo(SiteTestInputs inputs, string reports, string output,
55+
private static string[] CreateArguments(SiteTestInputs inputs, string reports, string output,
5256
string? additionalArgument)
57+
{
58+
var arguments = new List<string>
59+
{
60+
Path.Combine(inputs.Repository, SiteAssetTokens.BuilderRelativePath),
61+
$"{SiteTokens.ReportsArgument}={reports}",
62+
$"{SiteTokens.OutputArgument}={output}",
63+
$"{SiteTokens.RevisionArgument}={inputs.MeasuredRevision}",
64+
$"{SiteTokens.EvidenceArgument}={inputs.EvidenceUrl}",
65+
};
66+
if (additionalArgument is not null)
67+
{
68+
arguments.Add(additionalArgument);
69+
}
70+
71+
return arguments.ToArray();
72+
}
73+
74+
private static ProcessStartInfo CreateStartInfo(SiteTestInputs inputs, string[] arguments)
5375
{
5476
var start = new ProcessStartInfo(SiteTokens.NodeExecutable)
5577
{
@@ -59,14 +81,9 @@ private static ProcessStartInfo CreateStartInfo(SiteTestInputs inputs, string re
5981
UseShellExecute = false,
6082
CreateNoWindow = true,
6183
};
62-
start.ArgumentList.Add(Path.Combine(inputs.Repository, SiteAssetTokens.BuilderRelativePath));
63-
start.ArgumentList.Add($"{SiteTokens.ReportsArgument}={reports}");
64-
start.ArgumentList.Add($"{SiteTokens.OutputArgument}={output}");
65-
start.ArgumentList.Add($"{SiteTokens.RevisionArgument}={inputs.MeasuredRevision}");
66-
start.ArgumentList.Add($"{SiteTokens.EvidenceArgument}={inputs.EvidenceUrl}");
67-
if (additionalArgument is not null)
84+
foreach (var argument in arguments)
6885
{
69-
start.ArgumentList.Add(additionalArgument);
86+
start.ArgumentList.Add(argument);
7087
}
7188

7289
return start;

‎tests/KeyLoad.SiteTests/Features/BenchmarkComparisons/SiteBuildTests.cs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ public async Task AC_BC_016_ExistingAndDescendantOutputsAreRefusedWithoutResidue
106106
await File.WriteAllTextAsync(sentinel, SiteAssetTokens.SentinelValue, token);
107107
var existing = await SiteBuilderProcess.RunAsync(inputs, inputs.Reports, temporary.Output, token);
108108
await Assert.That(existing.ExitCode != SiteTokens.ProcessSuccessExitCode).IsTrue();
109+
await AssertBuilderError(existing, SiteBuilderTokens.OutputPathError);
109110
await Assert.That(await File.ReadAllTextAsync(sentinel, token)).IsEqualTo(SiteAssetTokens.SentinelValue);
110111

111112
var siteRoot = Path.Combine(inputs.Repository, SiteAssetTokens.SiteRootDirectory);
@@ -114,6 +115,7 @@ public async Task AC_BC_016_ExistingAndDescendantOutputsAreRefusedWithoutResidue
114115
var nested = Path.Combine(parent, $"{SiteAssetTokens.PreviewDirectoryPrefix}{Guid.NewGuid():N}");
115116
var rejected = await SiteBuilderProcess.RunAsync(inputs, inputs.Reports, nested, token);
116117
await Assert.That(rejected.ExitCode != SiteTokens.ProcessSuccessExitCode).IsTrue();
118+
await AssertBuilderError(rejected, SiteBuilderTokens.OutputPathError);
117119
await Assert.That(Directory.Exists(nested)).IsFalse();
118120
}
119121
}
@@ -132,6 +134,7 @@ public async Task AC_BC_016_SymlinkAndCorruptInputsAreRejectedBeforeOutputCreati
132134
var outputThroughLink = Path.Combine(linkedParent, SiteAssetTokens.OutputDirectory);
133135
var linked = await SiteBuilderProcess.RunAsync(inputs, inputs.Reports, outputThroughLink, token);
134136
await Assert.That(linked.ExitCode != SiteTokens.ProcessSuccessExitCode).IsTrue();
137+
await AssertBuilderError(linked, SiteBuilderTokens.SymlinkError);
135138
await Assert.That(Directory.Exists(Path.Combine(realParent, SiteAssetTokens.OutputDirectory))).IsFalse();
136139

137140
Directory.CreateDirectory(temporary.Reports);
@@ -143,6 +146,7 @@ public async Task AC_BC_016_SymlinkAndCorruptInputsAreRejectedBeforeOutputCreati
143146
var linkedInputOutput = Path.Combine(temporary.Path, SiteAssetTokens.LinkedInputOutput);
144147
var linkedInput = await SiteBuilderProcess.RunAsync(inputs, temporary.Reports, linkedInputOutput, token);
145148
await Assert.That(linkedInput.ExitCode != SiteTokens.ProcessSuccessExitCode).IsTrue();
149+
await AssertBuilderError(linkedInput, SiteBuilderTokens.SymlinkError);
146150
await Assert.That(Directory.Exists(linkedInputOutput)).IsFalse();
147151

148152
await using var corrupt = SiteTempDirectory.Create();
@@ -151,7 +155,14 @@ public async Task AC_BC_016_SymlinkAndCorruptInputsAreRejectedBeforeOutputCreati
151155
var failedOutput = Path.Combine(corrupt.Path, SiteAssetTokens.FailedOutput);
152156
var failed = await SiteBuilderProcess.RunAsync(inputs, corrupt.Reports, failedOutput, token);
153157
await Assert.That(failed.ExitCode != SiteTokens.ProcessSuccessExitCode).IsTrue();
158+
await AssertBuilderError(failed, SiteBuilderTokens.JsonParserMarker);
159+
await Assert.That(failed.StandardError.Contains(SiteBuilderTokens.VendorError, StringComparison.Ordinal)).IsFalse();
154160
await Assert.That(Directory.Exists(failedOutput)).IsFalse();
155161
}
156162

163+
private static async Task AssertBuilderError(SiteProcessResult result, string expected)
164+
{
165+
await Assert.That(result.StandardError.Contains(expected, StringComparison.Ordinal)).IsTrue();
166+
}
167+
157168
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
using System.Text.Json;
4+
5+
namespace KeyLoad.SiteTests.Features.BenchmarkComparisons;
6+
7+
internal static class SiteBuilderDiagnostics
8+
{
9+
private static readonly JsonSerializerOptions ReceiptOptions = new(JsonSerializerDefaults.Web)
10+
{
11+
WriteIndented = true,
12+
};
13+
private static readonly UTF8Encoding Utf8WithoutBom = new(encoderShouldEmitUTF8Identifier: false);
14+
15+
internal static string GetEvidenceRoot()
16+
{
17+
var configuredRoot = Environment.GetEnvironmentVariable(SiteCoverageTokens.CoverageRootEnvironment);
18+
if (string.IsNullOrWhiteSpace(configuredRoot) || !Path.IsPathFullyQualified(configuredRoot))
19+
{
20+
throw new InvalidOperationException(SiteBuilderTokens.CoverageRootMissing);
21+
}
22+
23+
return Directory.GetParent(Path.GetFullPath(configuredRoot))?.FullName is { } evidenceRoot
24+
? Path.Combine(evidenceRoot, SiteBuilderTokens.EvidenceDirectoryName)
25+
: throw new InvalidOperationException(SiteBuilderTokens.CoverageRootMissing);
26+
}
27+
28+
internal static async Task<SiteBuilderSourceReceipt[]> ReadBuilderSourcesAsync(SiteTestInputs inputs,
29+
CancellationToken cancellationToken)
30+
{
31+
var sources = new[] { SiteBuilderTokens.EntryBuilderPath, SiteBuilderTokens.FeatureBuilderPath };
32+
var receipts = new SiteBuilderSourceReceipt[sources.Length];
33+
for (var index = SiteTokens.Zero; index < sources.Length; index++)
34+
{
35+
var path = sources[index];
36+
var fullPath = Path.Combine(inputs.Repository,
37+
path.Replace(SiteTokens.UrlPathSeparatorCharacter, Path.DirectorySeparatorChar));
38+
var bytes = await File.ReadAllBytesAsync(fullPath, cancellationToken);
39+
receipts[index] = new(path, Convert.ToHexStringLower(SHA256.HashData(bytes)));
40+
}
41+
42+
return receipts;
43+
}
44+
45+
internal static async Task RetainAsync(SiteTestInputs inputs, string[] arguments,
46+
SiteBuilderSourceReceipt[] builderSources, SiteProcessResult result, CancellationToken cancellationToken)
47+
{
48+
var evidenceRoot = GetEvidenceRoot();
49+
Directory.CreateDirectory(evidenceRoot);
50+
var invocationDirectory = Path.Combine(evidenceRoot, Guid.NewGuid().ToString(SiteBuilderTokens.GuidFormat));
51+
Directory.CreateDirectory(invocationDirectory);
52+
await File.WriteAllTextAsync(Path.Combine(invocationDirectory, SiteBuilderTokens.StandardOutputFile),
53+
result.StandardOutput, Utf8WithoutBom, cancellationToken);
54+
await File.WriteAllTextAsync(Path.Combine(invocationDirectory, SiteBuilderTokens.StandardErrorFile),
55+
result.StandardError, Utf8WithoutBom, cancellationToken);
56+
var receipt = new SiteBuilderInvocationReceipt(SiteBuilderTokens.ReceiptSchemaVersion, inputs.SiteRevision,
57+
inputs.MeasuredRevision, inputs.EvidenceRun, inputs.EvidenceUrl, inputs.Repository, arguments,
58+
builderSources, result.ExitCode, SiteBuilderTokens.StandardOutputFile, SiteBuilderTokens.StandardErrorFile);
59+
var receiptBytes = JsonSerializer.SerializeToUtf8Bytes(receipt, ReceiptOptions);
60+
var stagingPath = Path.Combine(invocationDirectory, SiteBuilderTokens.InvocationStagingFile);
61+
await File.WriteAllBytesAsync(stagingPath, receiptBytes, cancellationToken);
62+
File.Move(stagingPath, Path.Combine(invocationDirectory, SiteBuilderTokens.InvocationFile));
63+
}
64+
65+
internal static string PreChromeFailure(SiteProcessResult result)
66+
=> $"{SiteBuilderTokens.BuilderFailurePrefix} {result.ExitCode}{SiteBuilderTokens.ErrorSeparator}{result.StandardError}";
67+
}
68+
69+
internal sealed record SiteBuilderSourceReceipt(string Path, string Sha256);
70+
71+
internal sealed record SiteBuilderInvocationReceipt(int SchemaVersion, string SiteSourceRevision,
72+
string MeasuredSourceRevision, string EvidenceRun, string EvidenceUrl, string WorkingDirectory,
73+
string[] Arguments, SiteBuilderSourceReceipt[] BuilderSources, int ExitCode, string StdoutFile,
74+
string StderrFile);

0 commit comments

Comments
 (0)