diff --git a/eng/pipelines/libraries/helix-cache-shadow.yml b/eng/pipelines/libraries/helix-cache-shadow.yml
new file mode 100644
index 00000000000000..0909d5a9f66ec3
--- /dev/null
+++ b/eng/pipelines/libraries/helix-cache-shadow.yml
@@ -0,0 +1,59 @@
+parameters:
+ enabled: false
+ archType: ''
+ osGroup: ''
+ osSubgroup: ''
+ buildConfig: ''
+ runtimeFlavor: ''
+ runtimeVariant: ''
+ helixQueues: ''
+ targetRid: ''
+ nameSuffix: ''
+ platform: ''
+ shouldContinueOnError: false
+
+steps:
+- ${{ if and(eq(parameters.enabled, 'true'), eq(parameters.platform, 'linux_x64'), eq(variables['System.TeamProject'], 'public')) }}:
+ # Persist completed entries only. Input snapshots are recreated in each job.
+ - task: Cache@2
+ displayName: Helix shadow result cache
+ continueOnError: true
+ inputs:
+ key: '"helix-results" | "v1" | "linux-x64" | "System.Memory.Data.Tests" | "${{ parameters.buildConfig }}" | "$(Build.BuildId)"'
+ path: $(Pipeline.Workspace)/.helix-cache/entries
+ restoreKeys: |
+ "helix-results" | "v1" | "linux-x64" | "System.Memory.Data.Tests" | "${{ parameters.buildConfig }}"
+
+ - script: |
+ set -euo pipefail
+ dotnet="$(Build.SourcesDirectory)/.dotnet/dotnet"
+ project="$(Build.SourcesDirectory)/src/tools/helix-cache"
+ output="$(Build.SourcesDirectory)/artifacts/log/$(_BuildConfig)/helix-cache-shadow"
+ cache="$(Pipeline.Workspace)/.helix-cache"
+ mkdir -p "$cache/entries"
+
+ "$dotnet" publish "$project/src/HelixCache.csproj" -c Release -r linux-x64 \
+ -o "$(Build.SourcesDirectory)/artifacts/tools/helix-cache"
+ executable="$(Build.SourcesDirectory)/artifacts/tools/helix-cache/helix-cache"
+ "$dotnet" test "$project/tests/HelixCache.Tests.csproj" -c Release
+
+ "$dotnet" msbuild "$(Build.SourcesDirectory)/src/libraries/sendtohelix.proj" \
+ -t:PrepareCorrelationPayloadDirectory -p:Configuration=$(_BuildConfig) -p:RuntimeFlavor=CoreCLR
+ settings="$("$dotnet" msbuild "$(Build.SourcesDirectory)/src/libraries/sendtohelix.proj" \
+ -nologo -p:Configuration=$(_BuildConfig) -getProperty:NetCoreAppCurrentBuildSettings)"
+ archives="$("$dotnet" msbuild "$(Build.SourcesDirectory)/src/libraries/System.Memory.Data/tests/System.Memory.Data.Tests.csproj" \
+ -nologo -p:Configuration=$(_BuildConfig) -getProperty:TestArchiveTestsDir)"
+ sdk="$("$dotnet" msbuild "$(Build.SourcesDirectory)/eng/testing/helix-cache-shadow.proj" \
+ -nologo -getProperty:HelixCacheSdkPackage)"
+ "$executable" prepare --cache "$cache" --output "$output" \
+ --payload "$archives/System.Memory.Data.Tests.zip" \
+ --runtime "$(Build.SourcesDirectory)/artifacts/helix/runtime/test-runtime-$settings.zip" \
+ --queue AzureLinux.3.Amd64.Open --helix-sdk "$sdk"
+
+ # A cache hit never bypasses this submission.
+ "$dotnet" msbuild "$(Build.SourcesDirectory)/eng/testing/helix-cache-shadow.proj" -t:Submit \
+ -p:HelixCacheShadowDirectory="$output" -p:ShadowCreator=dotnet-bot
+ "$executable" collect --cache "$cache" --output "$output"
+ displayName: Validate Helix cache in shadow mode (always execute)
+ continueOnError: true
+ timeoutInMinutes: 75
diff --git a/eng/pipelines/runtime.yml b/eng/pipelines/runtime.yml
index 36dbc5958e2034..00d4a2f5e5f376 100644
--- a/eng/pipelines/runtime.yml
+++ b/eng/pipelines/runtime.yml
@@ -1,3 +1,9 @@
+parameters:
+- name: helixCacheShadow
+ displayName: Validate Linux Helix result caching (shadow mode)
+ type: boolean
+ default: true
+
# Setting batch to true, triggers one build at a time.
# if there is a push while a build in progress, it will wait,
# until the running build finishes, and produce a build with all the changes
@@ -385,6 +391,9 @@ extends:
eq(variables['archType'], 'arm64'),
eq(variables['coreclrContainsChange'], true)),
eq(variables['isRollingBuild'], true))
+ - template: /eng/pipelines/libraries/helix-cache-shadow.yml
+ parameters:
+ enabled: ${{ parameters.helixCacheShadow }}
condition: >-
or(
eq(stageDependencies.EvaluatePaths.evaluate_paths.outputs['SetPathVars_non_mono_and_wasm.containsChange'], true),
diff --git a/eng/testing/helix-cache-shadow.proj b/eng/testing/helix-cache-shadow.proj
new file mode 100644
index 00000000000000..73b4a2bc98cea5
--- /dev/null
+++ b/eng/testing/helix-cache-shadow.proj
@@ -0,0 +1,53 @@
+
+
+
+
+ $(ShadowQueue)
+ $([System.IO.Path]::GetFullPath('$(MicrosoftDotNetHelixSdkTasksAssembly)/../../../microsoft.dotnet.helix.sdk.$(MicrosoftDotNetHelixSdkPackageVersion).nupkg'))
+
+
+
+
+
+ $(ShadowPayload)
+ $(ShadowCommand)
+ $(ShadowPostCommand)
+ $(ShadowTimeout)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+ $(ShadowCancellationToken)
+
+
+
+
+
diff --git a/src/tools/helix-cache/README.md b/src/tools/helix-cache/README.md
new file mode 100644
index 00000000000000..6f223feb5be3d3
--- /dev/null
+++ b/src/tools/helix-cache/README.md
@@ -0,0 +1,238 @@
+# Helix result cache prototype
+
+A C# NativeAOT CLI for a local, trusted filesystem cache of Linux x64 Helix
+work-item results. An opt-in Azure Pipelines shadow lane exercises actual
+submission and collection without replacing any test executions or results.
+
+## Build and exercise
+
+From the repository root, using its configured SDK and NuGet feeds:
+
+```sh
+dotnet publish src/tools/helix-cache/src/HelixCache.csproj -c Release -r linux-x64 \
+ -o artifacts/tools/helix-cache
+dotnet test src/tools/helix-cache/tests/HelixCache.Tests.csproj -c Release
+```
+
+NativeAOT publishing requires a supported native compiler/linker and development
+libraries. For managed development, build with `-p:PublishAot=false` and run the
+generated apphost with the SDK runtime available.
+
+## Structure
+
+`src/` contains the executable project; `tests/` is a sibling xUnit project.
+Tests exercise the published NativeAOT executable and inject HTTP responses for
+Helix collection. Neither project needs source-glob exclusions.
+
+The command pipeline is:
+
+1. `CommandLine` parses and validates arguments into command-specific records before any I/O.
+2. `Submission` validates work items and payload declarations; `LinuxEnvironment` resolves the declared queue environment.
+3. `Cache.Lookup` freezes shared inputs once per lookup, constructs execution identities, and checks completed entries.
+4. `HelixClient` handles typed HTTP responses, completion polling, and result downloads.
+5. `Results.Read` validates receipts and reports and produces a `TestRun` containing counts and comparable outcomes.
+6. `ShadowValidation` compares a fresh run with any cached run before `Cache.Store` publishes a passing entry atomically.
+
+JSON contracts use source-generated System.Text.Json serialization.
+`HelixJsonContext` uses the service's PascalCase names for transport models.
+`CacheJsonContext` uses camelCase for local models, including environment
+fingerprints. Image/provisioning descriptions remain opaque JSON because their
+open-ended fields must all participate in the key. Canonical key encoding is
+separate from deserialization and domain validation. Prototype formats and keys
+may change during development; existing entries are disposable.
+
+## Lookup
+
+```sh
+helix-cache lookup submission.json --cache /path/to/cache --output lookup.json
+```
+
+Example `submission.json`:
+
+```json
+{
+ "schemaVersion": 1,
+ "workItems": [
+ {
+ "name": "Example.Tests",
+ "queues": ["AzureLinux.3.Amd64.Open"],
+ "payload": { "path": "Example.Tests.zip" },
+ "correlationPayloads": [
+ { "path": "test-runtime.zip", "destination": "" }
+ ],
+ "command": "./RunTests.sh",
+ "preCommands": [],
+ "postCommands": [],
+ "environment": { "DOTNET_TieredCompilation": "1" },
+ "timeoutSeconds": 900,
+ "expectedExitCode": 0
+ }
+ ]
+}
+```
+
+Input paths are relative to the submission manifest. A payload is either a ZIP
+archive or a directory. ZIP snapshots are rewritten in ordinal entry order with
+fixed timestamps and compression, preserving file contents, external attributes
+(including Unix modes and symlinks), and empty directories. Duplicate paths,
+traversal, and file/directory conflicts are rejected. Directory snapshots preserve
+Unix modes and empty directories and normalize filesystem timestamps. Symlinks in
+directory inputs/results are rejected; put linked payloads in an archive. Shared
+inputs are captured once per invocation. Only frozen archives are rewritten;
+original archives are never changed.
+
+The tool resolves the actual host queue, requires an available Linux AMD64 queue,
+and fingerprints its declared image, ordered provisioning recipe and workspace
+path. Docker targets use Arcade's `(alias)host@image` syntax and must include a
+lowercase `@sha256:` digest. Operational queue state is excluded. The full target
+string remains part of the execution identity, including its alias.
+
+For offline use or deterministic tests, supply `--queue-info queues.json`, where
+the file is a trusted snapshot of the array returned by
+`https://helix.dot.net/api/2019-06-17/info/queues`. Without that option the tool
+fetches queue information once from that endpoint.
+
+Each `lookup.json` entry contains `status` (`hit` or `miss`), an `execution`
+record, and a `resultsPath` for hits. Hits and misses both exit zero; malformed
+inputs, HTTP failures, unreadable files and corrupt entries exit nonzero.
+Diagnostics go to stderr. The caller must retain every requested item when
+combining cached and fresh results, including an all-hit submission.
+
+**Submit the snapshot paths in the execution record, not the original input
+paths.** Treat snapshots as immutable. If the sender needs writable directories
+(the Helix SDK can write `.payload` markers), make separate submission copies
+from these snapshots. Do not resolve inputs again after computing the key.
+
+## Store completed results
+
+Save one entry's `execution` object from `lookup.json` as `execution.json`, and
+collect the original work item's results into a directory:
+
+```sh
+helix-cache store execution.json --results downloaded-results --cache /path/to/cache
+```
+
+The directory must include a `result.json` receipt constructed by the trusted
+result collector from Helix's completed-work-item status:
+
+```json
+{
+ "schemaVersion": 1,
+ "completed": true,
+ "infrastructureSucceeded": true,
+ "exitCode": 0,
+ "jobId": "00000000-0000-0000-0000-000000000001",
+ "workItemName": "Example.Tests",
+ "targetQueue": "AzureLinux.3.Amd64.Open",
+ "resultFiles": ["testResults.xml"]
+}
+```
+
+`infrastructureSucceeded` must be false for Helix infrastructure errors,
+cancellations or timeouts. Submission success is not completion. This tool does
+not independently authenticate the receipt with Helix.
+
+The prototype accepts xUnit XML (`assemblies` / `assembly` / `test`) only. Each assembly must
+contain tests whose results agree with its total/passed/failed/skipped counts;
+failures, errors, empty or incomplete reports are rejected. Skips are retained.
+All files in the results directory are copied and checksummed. The execution key
+and input snapshots are checked before publishing an entry. Atomic directory
+renames make concurrent stores safe; an existing valid entry wins.
+
+Store uses the environment captured at lookup, never today's queue metadata.
+Lookup checks artifact integrity and reports corruption as an error rather than
+returning a cached pass. Original job provenance and reports are retained.
+Cached results must be labeled as reused when published; no new Helix job is
+invented. TRX/JUnit, cache eviction and replacing normal MSBuild test submissions
+with cached results are outside this prototype.
+
+## Azure Pipelines shadow validation
+
+Queue `eng/pipelines/runtime.yml` with its `helixCacheShadow` boolean parameter
+enabled. This draft temporarily defaults to **true** for validation; disable it
+before merging. The additional steps run only in the public
+project's Linux x64 `CoreCLR_AllSubsets` job, after its normal submissions.
+They use the existing build's `System.Memory.Data.Tests.zip` and runtime archive
+and submit one additional work item to `AzureLinux.3.Amd64.Open`.
+
+`Cache@2` restores `$(Pipeline.Workspace)/.helix-cache/entries`, using a rolling
+per-build snapshot key and a stable restore prefix, as the compiler caches do.
+The cache task's build ID is **not** part of an execution digest. Only completed
+entries are persisted; frozen input snapshots are recreated in each job.
+Existing Azure Pipelines branch/PR cache scoping applies.
+
+All orchestration runs in the C# NativeAOT executable. Its `prepare` command
+records the full command, upload post-command, timeout, queue and SHA-256 digest
+of the pinned Helix SDK package, runs native lookup, and exports frozen paths to
+`submission.props`. `--helix-sdk` must identify that installed SDK's `.nupkg`.
+`eng/testing/helix-cache-shadow.proj` submits those exact archives through the
+pinned Helix SDK without injecting extra reporters. The SDK generates its own
+command wrapper, including chmod and exit-code handling; its package digest
+participates in the key as `commandGeneratorDigest`, and submission checks the
+installed package against that digest. The generated UUID script directory is
+not stable and is not fingerprinted literally. This assumes those random wrapper
+paths are irrelevant to the tests; it is not a byte-for-byte fingerprint of every
+SDK-generated archive. Its default target only
+validates inputs; submission requires **`-t:Submit`**, which waits for completion
+and cancels the submitted job on task cancellation.
+
+`collect` checks the public Helix API's job, submitted queue alias (or queue ID
+when no alias is reported), work-item identity, terminal state, infrastructure
+errors, failure reason and exit code, then downloads the uploaded
+xUnit XML and console log. It constructs the receipt from that completion,
+passes the report through native admission, and compares any hit with the fresh
+test identities/outcomes/skip reasons. Ordering, worker directory prefixes and
+execution timings are ignored. The resolved physical queue ID and submitted
+alias are preserved in provenance alongside the original cached job.
+On a hit, mismatched outcomes fail validation before store. Every hit still
+executes on Helix; no cached result is published as a new run.
+
+Evidence is included in the existing build-log artifact under
+`artifacts/log//helix-cache-shadow/`, including `lookup.json`,
+`execution.json`, `work-item.json`, `summary.json`, and fresh results. Shadow
+errors are visible but use `continueOnError`, so they do not replace or weaken
+the normal test decision. Internal queues and authentication are not supported
+by this first shadow lane.
+
+For local preparation without submitting, after building the archives:
+
+```sh
+sdk="$(dotnet msbuild eng/testing/helix-cache-shadow.proj -nologo -getProperty:HelixCacheSdkPackage)"
+artifacts/tools/helix-cache/helix-cache prepare \
+ --cache artifacts/tmp/helix-cache \
+ --output artifacts/log/Release/helix-cache-shadow \
+ --payload artifacts/helix/tests/linux.AnyCPU.Release/System.Memory.Data.Tests.zip \
+ --runtime artifacts/helix/runtime/test-runtime-net11.0-linux-Release-x64.zip \
+ --queue AzureLinux.3.Amd64.Open --helix-sdk "$sdk"
+dotnet msbuild eng/testing/helix-cache-shadow.proj -t:Validate \
+ -p:HelixCacheShadowDirectory="$PWD/artifacts/log/Release/helix-cache-shadow"
+```
+
+The integration suite mocks public completion/file endpoints; live submission,
+cross-build restoration and unchanged-rebuild hit rates require shadow validation.
+Repacking unchanged ZIP members no longer changes the frozen bytes or key.
+Changed binary contents, permissions, paths, and other execution inputs still
+invalidate results, even when source changes appear unrelated to a test.
+
+## Cache contract
+
+Keys include the schema namespace, work-item name, target queue, declared Linux
+environment, payload identities and destinations, ordered correlation overlays,
+commands, an optional command-generator digest, environment overrides, timeout
+and expected exit code. Callers that inject generated commands must fingerprint
+their generator or provide its actual frozen output. Object
+properties are sorted ordinally; command and overlay order is preserved. Local
+absolute paths and result timestamps are not key material.
+
+This is a fingerprint of the **declared Linux environment**, not an attestation of
+the worker's installed packages, kernel or CPU features. Native queues can drift
+without changing their published recipes; a pinned container identifies its
+userspace but not its host. External services and randomized/stress trials may
+also make result reuse inappropriate. Enable caching only where that equivalence
+contract is acceptable. Build-specific command arguments remain in the key;
+there is no heuristic removal of build IDs or reporting arguments.
+
+The cache and manifests are trusted local inputs, not a shared-cache security
+boundary. Input snapshots live under `inputs/v1`; completed results are under
+`entries/v1//`. Do not mutate entries or delete snapshots while
+their executions are outstanding.
diff --git a/src/tools/helix-cache/src/Cache.cs b/src/tools/helix-cache/src/Cache.cs
new file mode 100644
index 00000000000000..ae99b9646c780a
--- /dev/null
+++ b/src/tools/helix-cache/src/Cache.cs
@@ -0,0 +1,178 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+
+namespace HelixCache;
+
+internal sealed class Cache
+{
+ internal const int ProtocolVersion = 1;
+ private readonly string _root;
+
+ internal Cache(string directory) => _root = Path.GetFullPath(directory);
+
+ internal LookupResult Lookup(Submission submission, string baseDirectory, LinuxEnvironment environment)
+ {
+ submission.Validate();
+ Dictionary fingerprints = submission.WorkItems.SelectMany(item => item.Queues)
+ .Distinct(StringComparer.Ordinal).ToDictionary(target => target, environment.Resolve, StringComparer.Ordinal);
+ Dictionary snapshots = new(StringComparer.Ordinal);
+ LookupResult result = new();
+ foreach (WorkItem item in submission.WorkItems)
+ {
+ Snapshot payload = Capture(item.Payload, baseDirectory, snapshots);
+ List correlation = item.CorrelationPayloads.Select(p => Capture(p, baseDirectory, snapshots)).ToList();
+
+ foreach (string target in item.Queues)
+ {
+ ExecutionInputs inputs = new()
+ {
+ Name = item.Name,
+ TargetQueue = target,
+ EnvironmentFingerprint = fingerprints[target],
+ Payload = Identity(item.Payload, payload),
+ CorrelationPayloads = item.CorrelationPayloads.Select((p, i) => Identity(p, correlation[i])).ToList(),
+ Command = item.Command,
+ PreCommands = [.. item.PreCommands],
+ PostCommands = [.. item.PostCommands],
+ CommandGeneratorDigest = item.CommandGeneratorDigest,
+ Environment = new(item.Environment, StringComparer.Ordinal),
+ TimeoutSeconds = item.TimeoutSeconds,
+ ExpectedExitCode = item.ExpectedExitCode,
+ };
+ ExecutionRecord execution = new()
+ {
+ SchemaVersion = ProtocolVersion,
+ Key = ExecutionKey.Create(inputs),
+ Inputs = inputs,
+ PayloadPath = payload.Path,
+ CorrelationPayloadPaths = correlation.Select(p => p.Path).ToList(),
+ };
+ string entry = EntryPath(execution.Key);
+ bool hit = Directory.Exists(entry);
+ if (hit)
+ {
+ ValidateEntry(entry, execution.Key);
+ }
+ result.Entries.Add(new LookupEntry
+ {
+ Status = hit ? LookupStatus.Hit : LookupStatus.Miss,
+ Execution = execution,
+ ResultsPath = hit ? Path.Combine(entry, "results") : null,
+ });
+ }
+ }
+ return result;
+ }
+
+ internal void Store(ExecutionRecord execution, string results)
+ {
+ execution.Validate();
+ VerifySnapshot(execution.PayloadPath, execution.Inputs.Payload.Digest);
+ for (int i = 0; i < execution.CorrelationPayloadPaths.Count; i++)
+ {
+ VerifySnapshot(execution.CorrelationPayloadPaths[i], execution.Inputs.CorrelationPayloads[i].Digest);
+ }
+ results = Path.GetFullPath(results);
+ Snapshot.EnsureOutside(results, _root);
+ using TemporaryDirectory temporary = new(Path.Combine(_root, "tmp"));
+ Snapshot.EnsureOutside(results, temporary.Path);
+ string stagedResults = Path.Combine(temporary.Path, "results");
+ Snapshot.Copy(results, stagedResults);
+ TestSummary summary = Results.Read(stagedResults, execution).Summary;
+ StoredEntry entry = new()
+ {
+ SchemaVersion = ProtocolVersion,
+ Execution = execution,
+ CreatedUtc = DateTimeOffset.UtcNow,
+ ResultsDigest = Snapshot.Hash(stagedResults),
+ Summary = summary,
+ };
+ using (FileStream file = File.Create(Path.Combine(temporary.Path, "entry.json")))
+ {
+ JsonSerializer.Serialize(file, entry, CacheJsonContext.Default.StoredEntry);
+ file.Flush(flushToDisk: true);
+ }
+ string final = EntryPath(execution.Key);
+ Directory.CreateDirectory(Path.GetDirectoryName(final)!);
+ try
+ {
+ Directory.Move(temporary.Path, final);
+ }
+ catch (IOException) when (Directory.Exists(final))
+ {
+ ValidateEntry(final, execution.Key);
+ }
+ }
+
+ internal TestRun ReadResults(ExecutionRecord execution)
+ {
+ execution.Validate();
+ return ValidateEntry(EntryPath(execution.Key), execution.Key);
+ }
+
+ private Snapshot Capture(Payload payload, string baseDirectory, Dictionary snapshots)
+ {
+ string path = Path.GetFullPath(payload.Path, baseDirectory);
+ if (!snapshots.TryGetValue(path, out Snapshot? snapshot))
+ {
+ Snapshot.EnsureOutside(path, _root);
+ snapshot = Snapshot.Capture(path, _root);
+ snapshots.Add(path, snapshot);
+ }
+ return snapshot;
+ }
+
+ private static PayloadIdentity Identity(Payload payload, Snapshot snapshot) => new()
+ {
+ Digest = snapshot.Digest,
+ Destination = payload.Destination,
+ Kind = snapshot.Kind,
+ };
+
+ private static TestRun ValidateEntry(string directory, string key)
+ {
+ using FileStream file = File.OpenRead(Path.Combine(directory, "entry.json"));
+ StoredEntry entry = JsonSerializer.Deserialize(file, CacheJsonContext.Default.StoredEntry)
+ ?? throw new InvalidDataException($"Cache entry failed validation: {directory}.");
+ ValidateVersion(entry.SchemaVersion);
+ entry.Execution.Validate();
+ string results = Path.Combine(directory, "results");
+ if (entry.Execution.Key != key || Snapshot.Hash(results) != entry.ResultsDigest)
+ {
+ throw new InvalidDataException($"Cache entry failed validation: {directory}.");
+ }
+ TestRun run = Results.Read(results, entry.Execution);
+ if (run.Summary.Total != entry.Summary.Total || run.Summary.Passed != entry.Summary.Passed || run.Summary.Skipped != entry.Summary.Skipped)
+ {
+ throw new InvalidDataException($"Cache entry failed validation: {directory}.");
+ }
+ return run;
+ }
+
+ private static void VerifySnapshot(string path, string digest)
+ {
+ if (Snapshot.Hash(path) != digest)
+ {
+ throw new InvalidDataException($"Snapshot failed validation: {path}.");
+ }
+ }
+
+ private string EntryPath(string key) => Path.Combine(_root, "entries", "v1", key[..2], key);
+
+ internal static void ValidateVersion(int version)
+ {
+ if (version != ProtocolVersion)
+ {
+ throw new InvalidDataException($"Unsupported schema version {version}; expected {ProtocolVersion}.");
+ }
+ }
+
+ internal static bool IsDigest(string value) =>
+ value.Length == 64 && value.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f');
+}
diff --git a/src/tools/helix-cache/src/CommandLine.cs b/src/tools/helix-cache/src/CommandLine.cs
new file mode 100644
index 00000000000000..2e5109ce19aa2a
--- /dev/null
+++ b/src/tools/helix-cache/src/CommandLine.cs
@@ -0,0 +1,89 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+
+namespace HelixCache;
+
+internal abstract record Command(string CacheDirectory);
+internal sealed record LookupCommand(string Manifest, string CacheDirectory, string Output, string? QueueInfo) : Command(CacheDirectory);
+internal sealed record StoreCommand(string Manifest, string CacheDirectory, string Results) : Command(CacheDirectory);
+internal sealed record PrepareCommand(string CacheDirectory, string Output, string Payload, string Runtime, string Queue, string HelixSdk, string? QueueInfo) : Command(CacheDirectory);
+internal sealed record CollectCommand(string CacheDirectory, string Output, TimeSpan Wait) : Command(CacheDirectory);
+
+internal static class CommandLine
+{
+ internal const string Usage = """
+ Usage: helix-cache lookup submission.json --cache DIRECTORY --output lookup.json [--queue-info queues.json]
+ helix-cache store execution.json --results DIRECTORY --cache DIRECTORY
+ helix-cache prepare --cache DIRECTORY --output DIRECTORY --payload tests.zip --runtime runtime.zip --queue QUEUE --helix-sdk SDK.nupkg [--queue-info queues.json]
+ helix-cache collect --cache DIRECTORY --output DIRECTORY [--wait-seconds SECONDS]
+ Only Linux x64 queues and digest-pinned container references are supported.
+ """;
+
+ internal static Command Parse(string[] args)
+ {
+ if (args.Length == 0 || args[0] is not ("lookup" or "store" or "prepare" or "collect"))
+ {
+ throw new InvalidDataException(Usage);
+ }
+ bool hasManifest = args[0] is "lookup" or "store";
+ int firstOption = hasManifest ? 2 : 1;
+ if (args.Length < firstOption ||
+ (hasManifest && (string.IsNullOrWhiteSpace(args[1]) || args[1].StartsWith("--", StringComparison.Ordinal))))
+ {
+ throw new InvalidDataException($"Missing manifest for '{args[0]}'.\n{Usage}");
+ }
+ Dictionary options = new(StringComparer.Ordinal);
+ for (int i = firstOption; i < args.Length; i += 2)
+ {
+ string name = args[i];
+ if (!name.StartsWith("--", StringComparison.Ordinal) || i + 1 == args.Length ||
+ string.IsNullOrWhiteSpace(args[i + 1]) || args[i + 1].StartsWith("--", StringComparison.Ordinal))
+ {
+ throw new InvalidDataException($"Expected an option and value at '{name}'.\n{Usage}");
+ }
+ if (!options.TryAdd(name, args[i + 1]))
+ {
+ throw new InvalidDataException($"Duplicate option '{name}'.");
+ }
+ }
+
+ string cache = Required("--cache");
+ Command command = args[0] switch
+ {
+ "lookup" => new LookupCommand(args[1], cache, Required("--output"), Optional("--queue-info")),
+ "store" => new StoreCommand(args[1], cache, Required("--results")),
+ "prepare" => new PrepareCommand(cache, Required("--output"), Required("--payload"),
+ Required("--runtime"), Required("--queue"), Required("--helix-sdk"), Optional("--queue-info")),
+ "collect" => new CollectCommand(cache, Required("--output"), ParseWait(Optional("--wait-seconds"))),
+ _ => throw new UnreachableException(),
+ };
+ if (options.Count > 0)
+ {
+ throw new InvalidDataException($"Unknown option '{string.Join("', '", options.Keys)}'.");
+ }
+ return command;
+
+ string Required(string name) => Optional(name) ?? throw new InvalidDataException($"Missing required option '{name}'.\n{Usage}");
+ string? Optional(string name) => options.Remove(name, out string? value) ? value : null;
+ }
+
+ private static TimeSpan ParseWait(string? value)
+ {
+ if (value is null)
+ {
+ return TimeSpan.FromHours(1);
+ }
+ if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int seconds) ||
+ seconds <= 0 || seconds > (uint.MaxValue - 1) / 1000)
+ {
+ throw new InvalidDataException("--wait-seconds must be a positive number of seconds supported by a cancellation timer.");
+ }
+ return TimeSpan.FromSeconds(seconds);
+ }
+}
diff --git a/src/tools/helix-cache/src/ExecutionKey.cs b/src/tools/helix-cache/src/ExecutionKey.cs
new file mode 100644
index 00000000000000..4613d7dc040e44
--- /dev/null
+++ b/src/tools/helix-cache/src/ExecutionKey.cs
@@ -0,0 +1,51 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text.Json;
+
+namespace HelixCache;
+
+internal static class ExecutionKey
+{
+ internal static string Create(ExecutionInputs inputs)
+ {
+ using MemoryStream stream = new();
+ using (Utf8JsonWriter writer = new(stream))
+ {
+ WriteCanonical(writer, JsonSerializer.SerializeToElement(inputs, CacheJsonContext.Default.ExecutionInputs));
+ }
+ return Convert.ToHexStringLower(SHA256.HashData(stream.GetBuffer().AsSpan(0, checked((int)stream.Length))));
+ }
+
+ // Property order is not execution input, including within opaque Helix provisioning metadata.
+ private static void WriteCanonical(Utf8JsonWriter writer, JsonElement value)
+ {
+ switch (value.ValueKind)
+ {
+ case JsonValueKind.Object:
+ writer.WriteStartObject();
+ foreach (JsonProperty property in value.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
+ {
+ writer.WritePropertyName(property.Name);
+ WriteCanonical(writer, property.Value);
+ }
+ writer.WriteEndObject();
+ break;
+ case JsonValueKind.Array:
+ writer.WriteStartArray();
+ foreach (JsonElement item in value.EnumerateArray())
+ {
+ WriteCanonical(writer, item);
+ }
+ writer.WriteEndArray();
+ break;
+ default:
+ value.WriteTo(writer);
+ break;
+ }
+ }
+}
diff --git a/src/tools/helix-cache/src/HelixCache.csproj b/src/tools/helix-cache/src/HelixCache.csproj
new file mode 100644
index 00000000000000..ad2071f61a0bb1
--- /dev/null
+++ b/src/tools/helix-cache/src/HelixCache.csproj
@@ -0,0 +1,15 @@
+
+
+ helix-cache
+ $(NetCoreAppToolCurrent)
+ Exe
+ false
+ true
+ enable
+ false
+ true
+
+
+
+
+
diff --git a/src/tools/helix-cache/src/HelixClient.cs b/src/tools/helix-cache/src/HelixClient.cs
new file mode 100644
index 00000000000000..2e85d64827bfae
--- /dev/null
+++ b/src/tools/helix-cache/src/HelixClient.cs
@@ -0,0 +1,94 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace HelixCache;
+
+internal sealed class HelixClient(HttpClient client)
+{
+ private const string Api = "https://helix.dot.net/api/2019-06-17";
+
+ internal async Task> GetQueuesAsync(CancellationToken cancellationToken) =>
+ await client.GetFromJsonAsync($"{Api}/info/queues", HelixJsonContext.Default.ListHelixQueue, cancellationToken)
+ ?? throw new InvalidDataException("Helix returned no queue metadata.");
+
+ internal async Task GetJobAsync(string jobId, CancellationToken cancellationToken) =>
+ await client.GetFromJsonAsync($"{Api}/jobs/{jobId}/details", HelixJsonContext.Default.HelixJob, cancellationToken)
+ ?? throw new InvalidDataException("Helix returned no job details.");
+
+ internal async Task WaitForCompletionAsync(string jobId, string name, TimeSpan wait,
+ CancellationToken cancellationToken = default)
+ {
+ using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(wait);
+ try
+ {
+ while (true)
+ {
+ HelixWorkItem item = await client.GetFromJsonAsync(
+ $"{Api}/jobs/{jobId}/workitems/{Uri.EscapeDataString(name)}",
+ HelixJsonContext.Default.HelixWorkItem, timeout.Token)
+ ?? throw new InvalidDataException("Helix returned no work item.");
+ if (item.Job != jobId || item.Name != name)
+ {
+ throw new InvalidDataException("Helix returned a different job or work item.");
+ }
+ if (item.State is "Finished" or "Passed" or "Failed")
+ {
+ return item;
+ }
+ if (item.State is not ("Unscheduled" or "Waiting" or "Running"))
+ {
+ throw new InvalidDataException($"Unexpected Helix work item state: {item.State}.");
+ }
+ await Task.Delay(TimeSpan.FromSeconds(15), timeout.Token);
+ }
+ }
+ catch (OperationCanceledException ex) when (timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
+ {
+ throw new TimeoutException($"Helix job {jobId} did not complete within {wait.TotalSeconds} seconds.", ex);
+ }
+ }
+
+ internal async Task DownloadResultsAsync(HelixWorkItem item, string directory, CancellationToken cancellationToken)
+ {
+ List files = await client.GetFromJsonAsync(
+ $"{Api}/jobs/{item.Job}/workitems/{Uri.EscapeDataString(item.Name)}/files?latestOnly=true",
+ HelixJsonContext.Default.ListHelixFile, cancellationToken)
+ ?? throw new InvalidDataException("Helix returned no work item file list.");
+ if (files.Any(file => file is null))
+ {
+ throw new InvalidDataException("Helix work item files must not contain null.");
+ }
+ List reports = files.Where(file => file.Name == "testResults.xml").ToList();
+ if (reports.Count != 1)
+ {
+ throw new InvalidDataException($"Expected exactly one uploaded testResults.xml; found {reports.Count}.");
+ }
+ if (Directory.Exists(directory) || File.Exists(directory))
+ {
+ throw new IOException($"The result download directory already exists: {directory}.");
+ }
+ Directory.CreateDirectory(directory);
+ await DownloadAsync(reports[0].Link, Path.Combine(directory, "testResults.xml"), cancellationToken);
+ if (!string.IsNullOrEmpty(item.ConsoleOutputUri))
+ {
+ await DownloadAsync(item.ConsoleOutputUri, Path.Combine(directory, "console.log"), cancellationToken);
+ }
+ }
+
+ private async Task DownloadAsync(string uri, string path, CancellationToken cancellationToken)
+ {
+ using Stream source = await client.GetStreamAsync(uri, cancellationToken);
+ using FileStream destination = new(path, FileMode.CreateNew, FileAccess.Write);
+ await source.CopyToAsync(destination, cancellationToken);
+ }
+}
diff --git a/src/tools/helix-cache/src/HelixModels.cs b/src/tools/helix-cache/src/HelixModels.cs
new file mode 100644
index 00000000000000..5c34836c6199fa
--- /dev/null
+++ b/src/tools/helix-cache/src/HelixModels.cs
@@ -0,0 +1,57 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace HelixCache;
+
+internal sealed record HelixQueue
+{
+ public required string QueueId { get; init; }
+ public string? Architecture { get; init; }
+ public string? OperatingSystemGroup { get; init; }
+ public bool IsAvailable { get; init; }
+
+ [JsonExtensionData]
+ public Dictionary Metadata { get; init; } = [];
+}
+
+internal sealed record HelixJob
+{
+ public required string Name { get; init; }
+ public required string QueueId { get; init; }
+ public string? QueueAlias { get; init; }
+}
+
+internal sealed record HelixWorkItem
+{
+ public required string Job { get; init; }
+ public required string Name { get; init; }
+ public required string State { get; init; }
+ public int? ExitCode { get; init; }
+ public string? MachineName { get; init; }
+ public string? ConsoleOutputUri { get; init; }
+ public HelixFailure? FailureReason { get; init; }
+ public List? Errors { get; init; }
+}
+
+internal sealed class HelixFailure
+{
+ [JsonExtensionData]
+ public Dictionary Details { get; init; } = [];
+}
+
+internal sealed class HelixFile
+{
+ public required string Name { get; init; }
+ public required string Link { get; init; }
+}
+
+[JsonSourceGenerationOptions(AllowDuplicateProperties = false, RespectNullableAnnotations = true)]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(HelixJob))]
+[JsonSerializable(typeof(HelixWorkItem))]
+[JsonSerializable(typeof(List))]
+internal sealed partial class HelixJsonContext : JsonSerializerContext;
diff --git a/src/tools/helix-cache/src/LinuxEnvironment.cs b/src/tools/helix-cache/src/LinuxEnvironment.cs
new file mode 100644
index 00000000000000..d37a1928689dfb
--- /dev/null
+++ b/src/tools/helix-cache/src/LinuxEnvironment.cs
@@ -0,0 +1,102 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace HelixCache;
+
+internal sealed class LinuxEnvironment
+{
+ private readonly List _queues;
+ private readonly Dictionary _fingerprints = new(StringComparer.Ordinal);
+
+ internal LinuxEnvironment(List queues) => _queues = queues;
+
+ internal static async Task LoadAsync(string? queueInfo, HelixClient client, CancellationToken cancellationToken)
+ {
+ if (queueInfo is not null)
+ {
+ using FileStream file = File.OpenRead(queueInfo);
+ return new LinuxEnvironment(await JsonSerializer.DeserializeAsync(file, HelixJsonContext.Default.ListHelixQueue, cancellationToken)
+ ?? throw new InvalidDataException($"Invalid or missing queue metadata: {queueInfo}."));
+ }
+
+ return new LinuxEnvironment(await client.GetQueuesAsync(cancellationToken));
+ }
+
+ internal EnvironmentFingerprint Resolve(string target)
+ {
+ if (_fingerprints.TryGetValue(target, out EnvironmentFingerprint? fingerprint))
+ {
+ return fingerprint;
+ }
+
+ // Matches Arcade JobSender's (alias)host@container syntax; retain a digest's second '@'.
+ // https://github.com/dotnet/arcade/blob/7103cc604358625941ee1d105e662f0d697085c6/src/Microsoft.DotNet.Helix/JobSender/JobDefinition.cs#L374-L399
+ int separator = target.IndexOf('@');
+ string host = separator < 0 ? target : target[..separator];
+ string? container = separator < 0 ? null : target[(separator + 1)..];
+ if (host.StartsWith('('))
+ {
+ int close = host.IndexOf(')');
+ if (separator < 0 || close <= 1 || close >= host.Length - 1)
+ {
+ throw new InvalidDataException($"Invalid target queue: {target}.");
+ }
+ host = host[(close + 1)..];
+ }
+
+ if (container is not null)
+ {
+ int digestStart = container.LastIndexOf("@sha256:", StringComparison.Ordinal);
+ if (digestStart <= 0 || !Cache.IsDigest(container[(digestStart + "@sha256:".Length)..]))
+ {
+ throw new InvalidDataException($"Container reference '{container}' must end in an explicit @sha256: digest.");
+ }
+ }
+
+ HelixQueue? queue = null;
+ foreach (HelixQueue candidate in _queues)
+ {
+ if (candidate is null)
+ {
+ throw new InvalidDataException("Helix queue metadata must not contain null.");
+ }
+ if (string.Equals(candidate.QueueId, host, StringComparison.OrdinalIgnoreCase))
+ {
+ if (queue is not null)
+ {
+ throw new InvalidDataException($"Duplicate target queue: {target}.");
+ }
+ queue = candidate;
+ }
+ }
+
+ if (queue is not { Architecture: string architecture, OperatingSystemGroup: string operatingSystem } ||
+ !string.Equals(queue.OperatingSystemGroup, "linux", StringComparison.OrdinalIgnoreCase) ||
+ !string.Equals(queue.Architecture, "AMD64", StringComparison.OrdinalIgnoreCase) ||
+ !queue.IsAvailable)
+ {
+ throw new InvalidDataException($"Queue '{host}' is missing, unavailable, or not a Linux AMD64 queue.");
+ }
+
+ fingerprint = new EnvironmentFingerprint
+ {
+ QueueId = queue.QueueId,
+ Architecture = architecture,
+ OperatingSystemGroup = operatingSystem,
+ Container = container,
+ Provisioning = queue.Metadata.Where(p => p.Key is
+ "GalleryImage" or "PreInstalledImage" or "PreparedImage" or "Artifacts" or "WorkspacePath")
+ .ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal),
+ };
+ _fingerprints.Add(target, fingerprint);
+ return fingerprint;
+ }
+}
diff --git a/src/tools/helix-cache/src/Models.cs b/src/tools/helix-cache/src/Models.cs
new file mode 100644
index 00000000000000..f46d2a2055f85d
--- /dev/null
+++ b/src/tools/helix-cache/src/Models.cs
@@ -0,0 +1,280 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace HelixCache;
+
+internal enum PayloadKind
+{
+ [JsonStringEnumMemberName("directory")]
+ Directory,
+ [JsonStringEnumMemberName("archive")]
+ Archive,
+}
+
+internal enum LookupStatus
+{
+ [JsonStringEnumMemberName("miss")]
+ Miss,
+ [JsonStringEnumMemberName("hit")]
+ Hit,
+}
+
+internal enum OutcomeComparison
+{
+ [JsonStringEnumMemberName("not-applicable")]
+ NotApplicable,
+ [JsonStringEnumMemberName("match")]
+ Match,
+ [JsonStringEnumMemberName("mismatch")]
+ Mismatch,
+}
+
+internal sealed record Submission
+{
+ public required int SchemaVersion { get; init; }
+ public required List WorkItems { get; init; }
+
+ internal void Validate()
+ {
+ Cache.ValidateVersion(SchemaVersion);
+ HashSet names = new(StringComparer.Ordinal);
+ if (WorkItems.Count == 0)
+ {
+ throw new InvalidDataException("A submission must contain at least one work item.");
+ }
+ foreach (WorkItem item in WorkItems)
+ {
+ if (item is null || string.IsNullOrWhiteSpace(item.Name) || !names.Add(item.Name))
+ {
+ throw new InvalidDataException("Work items must be nonnull and have unique, nonempty names.");
+ }
+ item.Validate();
+ }
+ }
+}
+
+internal sealed record WorkItem
+{
+ public required string Name { get; init; }
+ public required List Queues { get; init; }
+ public required Payload Payload { get; init; }
+ public List CorrelationPayloads { get; init; } = [];
+ public required string Command { get; init; }
+ public List PreCommands { get; init; } = [];
+ public List PostCommands { get; init; } = [];
+ public string? CommandGeneratorDigest { get; init; }
+ public Dictionary Environment { get; init; } = [];
+ public required int TimeoutSeconds { get; init; }
+ public required int ExpectedExitCode { get; init; }
+
+ internal void Validate()
+ {
+ ValidateExecution(Command, TimeoutSeconds, PreCommands, PostCommands, Environment, CommandGeneratorDigest);
+ if (Queues.Count == 0 || Queues.Any(string.IsNullOrWhiteSpace) ||
+ Queues.Distinct(StringComparer.Ordinal).Count() != Queues.Count)
+ {
+ throw new InvalidDataException($"Work item '{Name}' requires unique, nonempty target queues.");
+ }
+ Payload.Validate();
+ foreach (Payload payload in CorrelationPayloads)
+ {
+ if (payload is null)
+ {
+ throw new InvalidDataException($"Work item '{Name}' has a null correlation payload.");
+ }
+ payload.Validate();
+ }
+ }
+
+ internal static void ValidateExecution(string command, int timeoutSeconds, List preCommands,
+ List postCommands, Dictionary environment, string? commandGeneratorDigest)
+ {
+ if (string.IsNullOrWhiteSpace(command) || timeoutSeconds <= 0 ||
+ preCommands.Any(c => c is null) || postCommands.Any(c => c is null) ||
+ environment.Any(p => string.IsNullOrEmpty(p.Key) || p.Value is null))
+ {
+ throw new InvalidDataException("Execution requires a nonempty command, positive timeout, nonnull commands and environment values.");
+ }
+ if (commandGeneratorDigest is not null && !Cache.IsDigest(commandGeneratorDigest))
+ {
+ throw new InvalidDataException("A command generator digest must be a lowercase SHA-256 digest.");
+ }
+ }
+}
+
+internal sealed record Payload
+{
+ public required string Path { get; init; }
+ public string Destination { get; init; } = "";
+
+ internal void Validate()
+ {
+ if (string.IsNullOrWhiteSpace(Path))
+ {
+ throw new InvalidDataException("A payload requires a nonempty path.");
+ }
+ Snapshot.ValidateRelativePath(Destination, allowEmpty: true);
+ }
+}
+
+internal sealed record PayloadIdentity
+{
+ public required string Digest { get; init; }
+ public required string Destination { get; init; }
+ public required PayloadKind Kind { get; init; }
+
+ internal void Validate()
+ {
+ if (!Cache.IsDigest(Digest) || Kind is not (PayloadKind.Directory or PayloadKind.Archive))
+ {
+ throw new InvalidDataException("A payload identity requires a SHA-256 digest and a directory or archive kind.");
+ }
+ Snapshot.ValidateRelativePath(Destination, allowEmpty: true);
+ }
+}
+
+internal sealed class EnvironmentFingerprint
+{
+ public required string QueueId { get; init; }
+ public required string Architecture { get; init; }
+ public required string OperatingSystemGroup { get; init; }
+ public string? Container { get; init; }
+
+ [JsonExtensionData]
+ public Dictionary Provisioning { get; init; } = [];
+}
+
+internal sealed record ExecutionInputs
+{
+ public required string Name { get; init; }
+ public required string TargetQueue { get; init; }
+ public required EnvironmentFingerprint EnvironmentFingerprint { get; init; }
+ public required PayloadIdentity Payload { get; init; }
+ public required List CorrelationPayloads { get; init; }
+ public required string Command { get; init; }
+ public required List PreCommands { get; init; }
+ public required List PostCommands { get; init; }
+ public string? CommandGeneratorDigest { get; init; }
+ public required Dictionary Environment { get; init; }
+ public required int TimeoutSeconds { get; init; }
+ public required int ExpectedExitCode { get; init; }
+}
+
+internal sealed record ExecutionRecord
+{
+ public required int SchemaVersion { get; init; }
+ public required string Key { get; init; }
+ public required ExecutionInputs Inputs { get; init; }
+ public required string PayloadPath { get; init; }
+ public required List CorrelationPayloadPaths { get; init; }
+
+ internal void Validate()
+ {
+ Cache.ValidateVersion(SchemaVersion);
+ WorkItem.ValidateExecution(Inputs.Command, Inputs.TimeoutSeconds, Inputs.PreCommands, Inputs.PostCommands, Inputs.Environment,
+ Inputs.CommandGeneratorDigest);
+ Inputs.Payload.Validate();
+ foreach (PayloadIdentity payload in Inputs.CorrelationPayloads)
+ {
+ if (payload is null)
+ {
+ throw new InvalidDataException("Execution correlation payload identities must not contain null.");
+ }
+ payload.Validate();
+ }
+ if (!Cache.IsDigest(Key) || ExecutionKey.Create(Inputs) != Key ||
+ string.IsNullOrWhiteSpace(Inputs.Name) || string.IsNullOrWhiteSpace(Inputs.TargetQueue) ||
+ string.IsNullOrWhiteSpace(PayloadPath) || CorrelationPayloadPaths.Any(string.IsNullOrWhiteSpace) ||
+ CorrelationPayloadPaths.Count != Inputs.CorrelationPayloads.Count)
+ {
+ throw new InvalidDataException($"Execution record failed validation: {Key}.");
+ }
+ }
+}
+
+internal sealed class LookupResult
+{
+ public int SchemaVersion { get; init; } = Cache.ProtocolVersion;
+ public List Entries { get; init; } = [];
+}
+
+internal sealed class LookupEntry
+{
+ public required LookupStatus Status { get; init; }
+ public required ExecutionRecord Execution { get; init; }
+ public string? ResultsPath { get; init; }
+}
+
+internal sealed record ResultReceipt
+{
+ public required int SchemaVersion { get; init; }
+ public required bool Completed { get; init; }
+ public required bool InfrastructureSucceeded { get; init; }
+ public required int ExitCode { get; init; }
+ public required string JobId { get; init; }
+ public required string WorkItemName { get; init; }
+ public required string TargetQueue { get; init; }
+ public required List ResultFiles { get; init; }
+}
+
+internal sealed class TestSummary
+{
+ public int Total { get; set; }
+ public int Passed { get; set; }
+ public int Skipped { get; set; }
+}
+
+internal sealed class StoredEntry
+{
+ public required int SchemaVersion { get; init; }
+ public required ExecutionRecord Execution { get; init; }
+ public required DateTimeOffset CreatedUtc { get; init; }
+ public required string ResultsDigest { get; init; }
+ public required TestSummary Summary { get; init; }
+}
+
+internal sealed record ShadowSummary
+{
+ public required string Key { get; init; }
+ public required LookupStatus Lookup { get; init; }
+ public required string JobId { get; init; }
+ public string Mode { get; init; } = "shadow";
+ public int? Tests { get; init; }
+ public OutcomeComparison? Comparison { get; init; }
+ public string? CachedJobId { get; init; }
+ public bool Stored { get; init; }
+}
+
+internal sealed class ExecutionProvenance
+{
+ public string Kind { get; init; } = "helix-execution";
+ public required string JobId { get; init; }
+ public string? QueueAlias { get; init; }
+ public required string ResolvedQueueId { get; init; }
+ public string? MachineName { get; init; }
+ public required string State { get; init; }
+}
+
+[JsonSourceGenerationOptions(
+ AllowDuplicateProperties = false,
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ WriteIndented = true,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
+ UseStringEnumConverter = true,
+ RespectNullableAnnotations = true)]
+[JsonSerializable(typeof(Submission))]
+[JsonSerializable(typeof(ExecutionRecord))]
+[JsonSerializable(typeof(ExecutionInputs))]
+[JsonSerializable(typeof(LookupResult))]
+[JsonSerializable(typeof(ResultReceipt))]
+[JsonSerializable(typeof(StoredEntry))]
+[JsonSerializable(typeof(ShadowSummary))]
+[JsonSerializable(typeof(ExecutionProvenance))]
+internal sealed partial class CacheJsonContext : JsonSerializerContext;
diff --git a/src/tools/helix-cache/src/Program.cs b/src/tools/helix-cache/src/Program.cs
new file mode 100644
index 00000000000000..067c190bd0ff6e
--- /dev/null
+++ b/src/tools/helix-cache/src/Program.cs
@@ -0,0 +1,122 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml;
+
+namespace HelixCache;
+
+internal static class Program
+{
+ private static async Task Main(string[] args)
+ {
+ using CancellationTokenSource cancellation = new();
+ ConsoleCancelEventHandler cancel = (_, e) =>
+ {
+ e.Cancel = true;
+ cancellation.Cancel();
+ };
+ Console.CancelKeyPress += cancel;
+ try
+ {
+ if (args is ["--help" or "-h"] or ["lookup" or "store" or "prepare" or "collect", "--help" or "-h"])
+ {
+ Console.WriteLine(CommandLine.Usage);
+ return 0;
+ }
+ Command command = CommandLine.Parse(args);
+ if (!OperatingSystem.IsLinux())
+ {
+ throw new PlatformNotSupportedException("This prototype requires a Linux host.");
+ }
+ await ExecuteAsync(command, cancellation.Token);
+ return 0;
+ }
+ catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
+ {
+ Console.Error.WriteLine("helix-cache: Cancelled.");
+ return 130;
+ }
+ catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException or JsonException or
+ HttpRequestException or TaskCanceledException or XmlException or ArgumentException or
+ OverflowException or PlatformNotSupportedException or TimeoutException)
+ {
+ Console.Error.WriteLine($"helix-cache: {ex.Message}");
+ return 1;
+ }
+ finally
+ {
+ Console.CancelKeyPress -= cancel;
+ }
+ }
+
+ private static async Task ExecuteAsync(Command command, CancellationToken cancellationToken)
+ {
+ Cache cache = new(command.CacheDirectory);
+ using HttpClient http = new() { Timeout = TimeSpan.FromSeconds(60) };
+ HelixClient helix = new(http);
+ switch (command)
+ {
+ case LookupCommand lookup:
+ {
+ string manifest = Path.GetFullPath(lookup.Manifest);
+ using FileStream input = File.OpenRead(manifest);
+ Submission submission = await JsonSerializer.DeserializeAsync(input, CacheJsonContext.Default.Submission, cancellationToken)
+ ?? throw new InvalidDataException($"Missing submission: {manifest}.");
+ submission.Validate();
+ LinuxEnvironment environment = await LinuxEnvironment.LoadAsync(lookup.QueueInfo, helix, cancellationToken);
+ LookupResult result = cache.Lookup(submission, Path.GetDirectoryName(manifest)!, environment);
+ WriteLookup(lookup.Output, result);
+ break;
+ }
+ case StoreCommand store:
+ {
+ using FileStream input = File.OpenRead(store.Manifest);
+ ExecutionRecord execution = await JsonSerializer.DeserializeAsync(input, CacheJsonContext.Default.ExecutionRecord, cancellationToken)
+ ?? throw new InvalidDataException($"Missing execution record: {store.Manifest}.");
+ cache.Store(execution, store.Results);
+ Console.Error.WriteLine($"Stored {execution.Key}.");
+ break;
+ }
+ case PrepareCommand prepare:
+ {
+ LinuxEnvironment environment = await LinuxEnvironment.LoadAsync(prepare.QueueInfo, helix, cancellationToken);
+ LookupEntry entry = ShadowValidation.Prepare(cache, prepare.Output, prepare.Payload, prepare.Runtime, prepare.Queue,
+ prepare.HelixSdk, environment);
+ Console.WriteLine($"Helix cache shadow: {entry.Status.ToString().ToLowerInvariant()} {entry.Execution.Key}; execution will NOT be skipped.");
+ break;
+ }
+ case CollectCommand collect:
+ {
+ ShadowSummary summary = await ShadowValidation.CollectAsync(cache, collect.Output, collect.Wait, helix, cancellationToken);
+ Console.WriteLine(JsonSerializer.Serialize(summary, CacheJsonContext.Default.ShadowSummary));
+ break;
+ }
+ }
+ }
+
+ private static void WriteLookup(string path, LookupResult result)
+ {
+ string output = Path.GetFullPath(path);
+ Directory.CreateDirectory(Path.GetDirectoryName(output)!);
+ string temporary = output + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ using (FileStream file = File.Create(temporary))
+ {
+ JsonSerializer.Serialize(file, result, CacheJsonContext.Default.LookupResult);
+ file.Flush(flushToDisk: true);
+ }
+ File.Move(temporary, output, overwrite: true);
+ }
+ finally
+ {
+ File.Delete(temporary);
+ }
+ }
+}
diff --git a/src/tools/helix-cache/src/Results.cs b/src/tools/helix-cache/src/Results.cs
new file mode 100644
index 00000000000000..ae4437d1c98b4e
--- /dev/null
+++ b/src/tools/helix-cache/src/Results.cs
@@ -0,0 +1,119 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace HelixCache;
+
+internal readonly record struct TestOutcome(string Assembly, string Name, string Result, string SkipReason);
+
+internal sealed record TestRun(ResultReceipt Receipt, TestSummary Summary, Dictionary Outcomes)
+{
+ internal bool HasSameOutcomes(TestRun other) =>
+ Outcomes.Count == other.Outcomes.Count &&
+ Outcomes.All(p => other.Outcomes.TryGetValue(p.Key, out int count) && count == p.Value);
+}
+
+internal static class Results
+{
+ internal static TestRun Read(string directory, ExecutionRecord execution)
+ {
+ using FileStream file = File.OpenRead(Path.Combine(directory, "result.json"));
+ ResultReceipt receipt = JsonSerializer.Deserialize(file, CacheJsonContext.Default.ResultReceipt)
+ ?? throw new InvalidDataException($"Missing result receipt: {directory}.");
+ Cache.ValidateVersion(receipt.SchemaVersion);
+ if (!receipt.Completed || !receipt.InfrastructureSucceeded ||
+ receipt.ExitCode != execution.Inputs.ExpectedExitCode ||
+ receipt.WorkItemName != execution.Inputs.Name || receipt.TargetQueue != execution.Inputs.TargetQueue ||
+ !Guid.TryParse(receipt.JobId, out _) || receipt.ResultFiles.Count == 0 ||
+ receipt.ResultFiles.Any(string.IsNullOrWhiteSpace))
+ {
+ throw new InvalidDataException($"Results are incomplete, unsuccessful, or do not match the execution record: {directory}.");
+ }
+
+ TestSummary summary = new();
+ Dictionary outcomes = [];
+ HashSet reports = new(StringComparer.Ordinal);
+ foreach (string report in receipt.ResultFiles)
+ {
+ Snapshot.ValidateRelativePath(report);
+ if (!reports.Add(report))
+ {
+ throw new InvalidDataException($"Duplicate report in {nameof(receipt.ResultFiles)}: {report}.");
+ }
+ ReadReport(Path.Combine(directory, report), summary, outcomes);
+ }
+ return new TestRun(receipt, summary, outcomes);
+ }
+
+ private static void ReadReport(string path, TestSummary summary, Dictionary outcomes)
+ {
+ using XmlReader reader = XmlReader.Create(path, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null });
+ XDocument document = XDocument.Load(reader);
+ if (document.Root is not XElement root || root.Name != "assemblies" || !root.Elements("assembly").Any() ||
+ document.Descendants("error").Any() || document.Descendants("failure").Any())
+ {
+ throw InvalidReport(path);
+ }
+ foreach (XElement assembly in root.Elements("assembly"))
+ {
+ string? assemblyName = Path.GetFileName((string?)assembly.Attribute("name"));
+ if (string.IsNullOrWhiteSpace(assemblyName))
+ {
+ throw InvalidReport(path);
+ }
+ TestSummary actual = new();
+ foreach (XElement test in assembly.Elements("collection").Elements("test"))
+ {
+ string? name = (string?)test.Attribute("name");
+ string? result = (string?)test.Attribute("result");
+ if (string.IsNullOrWhiteSpace(name) || result is not ("Pass" or "Skip"))
+ {
+ throw InvalidReport(path);
+ }
+ TestOutcome outcome = new(assemblyName, name, result,
+ result == "Skip" ? (string?)test.Element("reason")?.Element("message") ?? "" : "");
+ outcomes.TryGetValue(outcome, out int count);
+ checked
+ {
+ outcomes[outcome] = count + 1;
+ actual.Total++;
+ actual.Passed += result == "Pass" ? 1 : 0;
+ actual.Skipped += result == "Skip" ? 1 : 0;
+ }
+ }
+ if (actual.Total == 0 || assembly.Descendants("test").Count() != actual.Total ||
+ Count(assembly, "total") != actual.Total || Count(assembly, "passed") != actual.Passed ||
+ Count(assembly, "skipped") != actual.Skipped || Count(assembly, "failed") != 0 ||
+ (assembly.Attribute("errors") is not null && Count(assembly, "errors") != 0))
+ {
+ throw InvalidReport(path);
+ }
+ checked
+ {
+ summary.Total += actual.Total;
+ summary.Passed += actual.Passed;
+ summary.Skipped += actual.Skipped;
+ }
+ }
+ }
+
+ private static InvalidDataException InvalidReport(string path) =>
+ new($"Expected a completed, nonempty, passing xUnit report with named tests and assemblies: {path}.");
+
+ private static int Count(XElement assembly, string name)
+ {
+ if (!int.TryParse((string?)assembly.Attribute(name), NumberStyles.None, CultureInfo.InvariantCulture, out int count))
+ {
+ throw new InvalidDataException($"Invalid xUnit report count: {name}.");
+ }
+ return count;
+ }
+}
diff --git a/src/tools/helix-cache/src/ShadowValidation.cs b/src/tools/helix-cache/src/ShadowValidation.cs
new file mode 100644
index 00000000000000..cc0908df29e410
--- /dev/null
+++ b/src/tools/helix-cache/src/ShadowValidation.cs
@@ -0,0 +1,172 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+
+namespace HelixCache;
+
+internal static class ShadowValidation
+{
+ internal const string WorkItem = "System.Memory.Data.Tests";
+ internal const string Command = "bash ./RunTests.sh --runtime-path \"$HELIX_CORRELATION_PAYLOAD\"";
+ internal const string PostCommand = "cp testResults.xml \"$HELIX_WORKITEM_UPLOAD_ROOT/testResults.xml\"";
+
+ internal static LookupEntry Prepare(Cache cache, string output, string payload, string runtime, string queue, string helixSdk,
+ LinuxEnvironment environment)
+ {
+ payload = Path.GetFullPath(payload);
+ runtime = Path.GetFullPath(runtime);
+ foreach (string archive in new[] { payload, runtime })
+ {
+ if (!File.Exists(archive) || !archive.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException($"The shadow submitter requires an existing ZIP archive: {archive}.");
+ }
+ }
+ output = Path.GetFullPath(output);
+ if (Directory.Exists(output) || File.Exists(output))
+ {
+ throw new IOException($"The shadow output directory already exists: {output}.");
+ }
+ Submission submission = new()
+ {
+ SchemaVersion = Cache.ProtocolVersion,
+ WorkItems = [new WorkItem
+ {
+ Name = WorkItem,
+ Queues = [queue],
+ Payload = new Payload { Path = payload },
+ CorrelationPayloads = [new Payload { Path = runtime }],
+ Command = Command,
+ PostCommands = [PostCommand],
+ CommandGeneratorDigest = Snapshot.HashFile(helixSdk),
+ TimeoutSeconds = 900,
+ ExpectedExitCode = 0,
+ }],
+ };
+ LookupResult lookup = cache.Lookup(submission, output, environment);
+ LookupEntry entry = lookup.Entries.Single();
+
+ Directory.CreateDirectory(output);
+ File.WriteAllText(Path.Combine(output, "submission.json"), JsonSerializer.Serialize(submission, CacheJsonContext.Default.Submission));
+ File.WriteAllText(Path.Combine(output, "lookup.json"), JsonSerializer.Serialize(lookup, CacheJsonContext.Default.LookupResult));
+ File.WriteAllText(Path.Combine(output, "execution.json"), JsonSerializer.Serialize(entry.Execution, CacheJsonContext.Default.ExecutionRecord));
+ WriteSubmissionProperties(output, entry.Execution);
+ return entry;
+ }
+
+ internal static async Task CollectAsync(Cache cache, string output, TimeSpan wait, HelixClient helix,
+ CancellationToken cancellationToken = default)
+ {
+ output = Path.GetFullPath(output);
+ LookupResult lookup = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(output, "lookup.json")),
+ CacheJsonContext.Default.LookupResult) ?? throw new InvalidDataException("Missing shadow lookup result.");
+ Cache.ValidateVersion(lookup.SchemaVersion);
+ if (lookup.Entries.Count != 1 || lookup.Entries[0] is not LookupEntry entry ||
+ entry.Status is not (LookupStatus.Hit or LookupStatus.Miss))
+ {
+ throw new InvalidDataException("Expected exactly one shadow lookup entry with hit or miss status.");
+ }
+ ExecutionRecord execution = entry.Execution;
+ execution.Validate();
+ TestRun? cached = entry.Status == LookupStatus.Hit ? cache.ReadResults(execution) : null;
+ if (!Guid.TryParse(File.ReadAllText(Path.Combine(output, "job-id.txt")).Trim(), out Guid job))
+ {
+ throw new InvalidDataException("Expected a Helix job GUID in job-id.txt.");
+ }
+ string jobId = job.ToString();
+ ShadowSummary summary = new() { Key = execution.Key, Lookup = entry.Status, JobId = jobId };
+ string summaryPath = Path.Combine(output, "summary.json");
+ File.WriteAllText(summaryPath, JsonSerializer.Serialize(summary, CacheJsonContext.Default.ShadowSummary));
+
+ HelixJob jobDetails = await helix.GetJobAsync(jobId, cancellationToken);
+ string submittedQueue = string.IsNullOrEmpty(jobDetails.QueueAlias) ? jobDetails.QueueId : jobDetails.QueueAlias;
+ if (jobDetails.Name != jobId ||
+ !string.Equals(submittedQueue, execution.Inputs.EnvironmentFingerprint.QueueId, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException("Helix returned a different job or target queue.");
+ }
+ HelixWorkItem item = await helix.WaitForCompletionAsync(jobId, execution.Inputs.Name, wait, cancellationToken);
+ File.WriteAllText(Path.Combine(output, "work-item.json"), JsonSerializer.Serialize(item, HelixJsonContext.Default.HelixWorkItem));
+ if (item.State is not ("Finished" or "Passed") || item.FailureReason is not null ||
+ item.Errors is { Count: > 0 } || item.ExitCode is null || item.ExitCode != execution.Inputs.ExpectedExitCode)
+ {
+ throw new InvalidDataException($"Helix work item did not complete successfully; see {Path.Combine(output, "work-item.json")}.");
+ }
+
+ string results = Path.Combine(output, "results");
+ await helix.DownloadResultsAsync(item, results, cancellationToken);
+ ResultReceipt receipt = new()
+ {
+ SchemaVersion = Cache.ProtocolVersion,
+ Completed = true,
+ InfrastructureSucceeded = true,
+ ExitCode = item.ExitCode.Value,
+ JobId = jobId,
+ WorkItemName = item.Name,
+ TargetQueue = execution.Inputs.TargetQueue,
+ ResultFiles = ["testResults.xml"],
+ };
+ ExecutionProvenance provenance = new()
+ {
+ JobId = jobId,
+ QueueAlias = jobDetails.QueueAlias,
+ ResolvedQueueId = jobDetails.QueueId,
+ MachineName = item.MachineName,
+ State = item.State,
+ };
+ File.WriteAllText(Path.Combine(results, "result.json"), JsonSerializer.Serialize(receipt, CacheJsonContext.Default.ResultReceipt));
+ File.WriteAllText(Path.Combine(results, "provenance.json"), JsonSerializer.Serialize(provenance, CacheJsonContext.Default.ExecutionProvenance));
+
+ TestRun fresh = Results.Read(results, execution);
+ summary = summary with
+ {
+ Tests = fresh.Summary.Total,
+ CachedJobId = cached?.Receipt.JobId,
+ Comparison = cached is null ? OutcomeComparison.NotApplicable :
+ cached.HasSameOutcomes(fresh) ? OutcomeComparison.Match : OutcomeComparison.Mismatch,
+ };
+ if (summary.Comparison == OutcomeComparison.Mismatch)
+ {
+ File.WriteAllText(summaryPath, JsonSerializer.Serialize(summary, CacheJsonContext.Default.ShadowSummary));
+ throw new InvalidDataException($"Cached and fresh test outcomes differ; see {summaryPath}.");
+ }
+ cache.Store(execution, results);
+ summary = summary with { Stored = true };
+ File.WriteAllText(summaryPath, JsonSerializer.Serialize(summary, CacheJsonContext.Default.ShadowSummary));
+ return summary;
+ }
+
+ private static void WriteSubmissionProperties(string output, ExecutionRecord execution)
+ {
+ XElement properties = new("PropertyGroup");
+ foreach ((string name, string value) in new Dictionary
+ {
+ ["ShadowName"] = execution.Inputs.Name,
+ ["ShadowQueue"] = execution.Inputs.TargetQueue,
+ ["ShadowPayload"] = execution.PayloadPath,
+ ["ShadowRuntime"] = execution.CorrelationPayloadPaths[0],
+ ["ShadowCommand"] = execution.Inputs.Command,
+ ["ShadowPostCommand"] = execution.Inputs.PostCommands[0],
+ ["ShadowCommandGeneratorDigest"] = execution.Inputs.CommandGeneratorDigest!,
+ ["ShadowTimeout"] = TimeSpan.FromSeconds(execution.Inputs.TimeoutSeconds).ToString("c", CultureInfo.InvariantCulture),
+ })
+ {
+ string escaped = value;
+ foreach (char character in "%$@();'*?")
+ {
+ escaped = escaped.Replace(character.ToString(), $"%{(int)character:X2}", StringComparison.Ordinal);
+ }
+ properties.Add(new XElement(name, escaped));
+ }
+ new XDocument(new XElement("Project", properties)).Save(Path.Combine(output, "submission.props"));
+ }
+}
diff --git a/src/tools/helix-cache/src/Snapshot.cs b/src/tools/helix-cache/src/Snapshot.cs
new file mode 100644
index 00000000000000..cdd58cce08aa5d
--- /dev/null
+++ b/src/tools/helix-cache/src/Snapshot.cs
@@ -0,0 +1,223 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text.Json;
+
+namespace HelixCache;
+
+internal sealed class Snapshot
+{
+ private static readonly DateTime s_timestamp = new(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ internal required string Path { get; init; }
+ internal required string Digest { get; init; }
+ internal required PayloadKind Kind { get; init; }
+
+ internal static Snapshot Capture(string source, string cache)
+ {
+ source = System.IO.Path.GetFullPath(source);
+ FileAttributes attributes = File.GetAttributes(source);
+ RejectLink(source, attributes);
+ bool directory = (attributes & FileAttributes.Directory) != 0;
+ if (!directory && !source.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException($"Payload '{source}' must be a directory or a ZIP archive.");
+ }
+
+ using TemporaryDirectory temporary = new(System.IO.Path.Combine(cache, "tmp"));
+ EnsureOutside(source, temporary.Path);
+ string name = directory ? "payload" : System.IO.Path.GetFileName(source);
+ string staged = System.IO.Path.Combine(temporary.Path, name);
+ if (directory)
+ {
+ Copy(source, staged);
+ }
+ else
+ {
+ NormalizeArchive(source, staged);
+ File.SetLastWriteTimeUtc(staged, s_timestamp);
+ File.SetUnixFileMode(staged, File.GetUnixFileMode(source));
+ }
+ string digest = Hash(staged);
+ string parent = System.IO.Path.Combine(cache, "inputs", "v1");
+ Directory.CreateDirectory(parent);
+ string final = System.IO.Path.Combine(parent, digest);
+ try
+ {
+ Directory.Move(temporary.Path, final);
+ }
+ catch (IOException) when (Directory.Exists(final))
+ {
+ string existing = System.IO.Path.Combine(final, name);
+ if (Hash(existing) != digest)
+ {
+ throw new InvalidDataException($"Snapshot failed validation: {existing}.");
+ }
+ }
+
+ return new Snapshot { Path = System.IO.Path.Combine(final, name), Digest = digest, Kind = directory ? PayloadKind.Directory : PayloadKind.Archive };
+ }
+
+ internal static void EnsureOutside(string source, string destination)
+ {
+ if (Directory.Exists(source) &&
+ (destination == source || destination.StartsWith(System.IO.Path.TrimEndingDirectorySeparator(source) + "/", StringComparison.Ordinal)))
+ {
+ throw new InvalidDataException($"Cache or staging directory '{destination}' must not be inside input directory '{source}'.");
+ }
+ }
+
+ internal static void ValidateRelativePath(string path, bool allowEmpty = false)
+ {
+ if ((!allowEmpty && string.IsNullOrWhiteSpace(path)) || System.IO.Path.IsPathRooted(path) ||
+ path.Split('/').Any(p => p is "." or "..") || path.Contains('\\'))
+ {
+ throw new InvalidDataException($"Expected a relative path without traversal: {path}.");
+ }
+ }
+
+ internal static void Copy(string source, string destination)
+ {
+ FileAttributes attributes = File.GetAttributes(source);
+ RejectLink(source, attributes);
+ UnixFileMode mode = File.GetUnixFileMode(source);
+ if ((attributes & FileAttributes.Directory) != 0)
+ {
+ Directory.CreateDirectory(destination);
+ foreach (string child in Directory.EnumerateFileSystemEntries(source).Order(StringComparer.Ordinal))
+ {
+ Copy(child, System.IO.Path.Combine(destination, System.IO.Path.GetFileName(child)));
+ }
+ Directory.SetLastWriteTimeUtc(destination, s_timestamp);
+ }
+ else
+ {
+ using (FileStream input = File.OpenRead(source))
+ using (FileStream output = new(destination, FileMode.CreateNew, FileAccess.Write))
+ {
+ input.CopyTo(output);
+ }
+ File.SetLastWriteTimeUtc(destination, s_timestamp);
+ }
+ File.SetUnixFileMode(destination, mode);
+ }
+
+ internal static string Hash(string path)
+ {
+ using MemoryStream manifest = new();
+ using (Utf8JsonWriter writer = new(manifest))
+ {
+ writer.WriteStartArray();
+ WriteTree(writer, path, Directory.Exists(path) ? "" : System.IO.Path.GetFileName(path));
+ writer.WriteEndArray();
+ }
+ return Convert.ToHexStringLower(SHA256.HashData(manifest.GetBuffer().AsSpan(0, checked((int)manifest.Length))));
+ }
+
+ internal static string HashFile(string path)
+ {
+ using FileStream file = File.OpenRead(path);
+ return Convert.ToHexStringLower(SHA256.HashData(file));
+ }
+
+ private static void NormalizeArchive(string source, string destination)
+ {
+ using ZipArchive input = ZipFile.OpenRead(source);
+ Dictionary paths = new(StringComparer.Ordinal);
+ foreach (ZipArchiveEntry entry in input.Entries)
+ {
+ string path = entry.FullName.TrimEnd('/');
+ ValidateRelativePath(path);
+ if (path.Split('/').Any(string.IsNullOrEmpty) || entry.FullName.EndsWith("//", StringComparison.Ordinal) ||
+ path.Contains('\0') || !paths.TryAdd(path, entry) ||
+ (entry.FullName.EndsWith('/') && entry.Length != 0))
+ {
+ throw new InvalidDataException($"Invalid or duplicate ZIP entry '{entry.FullName}' in '{source}'.");
+ }
+ }
+ foreach (string path in paths.Keys)
+ {
+ for (int separator = path.IndexOf('/'); separator >= 0; separator = path.IndexOf('/', separator + 1))
+ {
+ if (paths.TryGetValue(path[..separator], out ZipArchiveEntry? parent) && !parent.FullName.EndsWith('/'))
+ {
+ throw new InvalidDataException($"ZIP entry '{path}' has a non-directory parent in '{source}'.");
+ }
+ }
+ }
+
+ using FileStream file = new(destination, FileMode.CreateNew, FileAccess.Write);
+ using ZipArchive output = new(file, ZipArchiveMode.Create);
+ foreach (ZipArchiveEntry entry in input.Entries.OrderBy(e => e.FullName, StringComparer.Ordinal))
+ {
+ ZipArchiveEntry normalized = output.CreateEntry(entry.FullName, CompressionLevel.Fastest);
+ normalized.LastWriteTime = new DateTimeOffset(s_timestamp);
+ normalized.ExternalAttributes = entry.ExternalAttributes;
+ using Stream contents = entry.Open();
+ using Stream target = normalized.Open();
+ contents.CopyTo(target);
+ }
+ }
+
+ private static void WriteTree(Utf8JsonWriter writer, string path, string relative)
+ {
+ FileAttributes attributes = File.GetAttributes(path);
+ RejectLink(path, attributes);
+ bool directory = (attributes & FileAttributes.Directory) != 0;
+ writer.WriteStartArray();
+ writer.WriteStringValue(relative);
+ writer.WriteStringValue(directory ? "directory" : "file");
+ writer.WriteNumberValue((int)File.GetUnixFileMode(path));
+ if (!directory)
+ {
+ writer.WriteStringValue(HashFile(path));
+ }
+ writer.WriteEndArray();
+ if (directory)
+ {
+ foreach (string child in Directory.EnumerateFileSystemEntries(path).Order(StringComparer.Ordinal))
+ {
+ string name = System.IO.Path.GetFileName(child);
+ WriteTree(writer, child, relative.Length == 0 ? name : relative + "/" + name);
+ }
+ }
+ }
+
+ private static void RejectLink(string path, FileAttributes attributes)
+ {
+ if ((attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ throw new InvalidDataException($"Symbolic links are not supported in directory payloads or results: {path}. Use an archive for payloads containing links.");
+ }
+ }
+}
+
+internal sealed class TemporaryDirectory : IDisposable
+{
+ internal string Path { get; }
+
+ internal TemporaryDirectory(string parent)
+ {
+ Path = System.IO.Path.Combine(parent, Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(Path))
+ {
+ // Copied inputs can contain read-only directories.
+ foreach (string directory in Directory.EnumerateDirectories(Path, "*", SearchOption.AllDirectories).Prepend(Path))
+ {
+ File.SetUnixFileMode(directory, File.GetUnixFileMode(directory) | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+ Directory.Delete(Path, recursive: true);
+ }
+ }
+}
diff --git a/src/tools/helix-cache/tests/CacheFixture.cs b/src/tools/helix-cache/tests/CacheFixture.cs
new file mode 100644
index 00000000000000..5a32c837b1173b
--- /dev/null
+++ b/src/tools/helix-cache/tests/CacheFixture.cs
@@ -0,0 +1,177 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace HelixCache.Tests;
+
+internal sealed class CacheFixture : IDisposable
+{
+ internal const int ArchiveFileMode = 0x81A4;
+ internal const int ArchiveExecutableMode = 0x81ED;
+ internal const int ArchiveDirectoryMode = 0x41ED;
+ internal const int ArchiveLinkMode = 0xA1FF;
+ private const UnixFileMode ExecutableMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
+ UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
+ internal const string FirstJob = "00000000-0000-0000-0000-000000000001";
+ internal const string SecondJob = "00000000-0000-0000-0000-000000000002";
+ internal const string Report = """
+
+
+
+ """;
+
+ internal string Root { get; } = Directory.CreateTempSubdirectory("helix-cache-test-").FullName;
+ internal string CacheDirectory { get; set; }
+ internal string PayloadDirectory { get; }
+ internal string Archive { get; }
+ internal List Queues { get; }
+ internal Submission Submission { get; set; }
+ internal WorkItem Item
+ {
+ get => Submission.WorkItems[0];
+ set => Submission.WorkItems[0] = value;
+ }
+
+ internal CacheFixture()
+ {
+ CacheDirectory = Path.Combine(Root, "cache");
+ PayloadDirectory = Directory.CreateDirectory(Path.Combine(Root, "payload")).FullName;
+ File.SetUnixFileMode(PayloadDirectory, ExecutableMode);
+ File.WriteAllText(Path.Combine(PayloadDirectory, "run.sh"), "#!/bin/sh\nexit 0\n");
+ File.SetUnixFileMode(Path.Combine(PayloadDirectory, "run.sh"), ExecutableMode);
+ Archive = Path.Combine(Root, "shared.zip");
+ WriteArchive(Archive, [("shared.txt", "shared payload", ArchiveFileMode)]);
+ Queues = [new HelixQueue
+ {
+ QueueId = "linux.test.open", Architecture = "AMD64", OperatingSystemGroup = "linux", IsAvailable = true,
+ Metadata = new Dictionary
+ {
+ ["GalleryImage"] = JsonSerializer.SerializeToElement(new { Version = "1" }),
+ ["Artifacts"] = JsonSerializer.SerializeToElement(new[] { new { Id = "prepare", Parameters = new { version = "1" } } }),
+ },
+ }];
+ Submission = new Submission
+ {
+ SchemaVersion = 1,
+ WorkItems = [new WorkItem
+ {
+ Name = "suite", Queues = [Queues[0].QueueId], Payload = new Payload { Path = "payload" },
+ CorrelationPayloads = [new Payload { Path = "shared.zip", Destination = "runtime" }],
+ Command = "./run.sh", Environment = new() { ["SECOND"] = "2", ["FIRST"] = "1" },
+ TimeoutSeconds = 60, ExpectedExitCode = 0,
+ }],
+ };
+ }
+
+ internal static void WriteArchive(string path, (string Name, string Content, int Mode)[] entries, bool repack = false)
+ {
+ using FileStream file = File.Create(path);
+ using ZipArchive archive = new(file, ZipArchiveMode.Create);
+ foreach ((string name, string content, int mode) in repack ? entries.Reverse() : entries)
+ {
+ ZipArchiveEntry entry = archive.CreateEntry(name, repack ? CompressionLevel.NoCompression : CompressionLevel.Optimal);
+ entry.LastWriteTime = new DateTimeOffset(repack ? 2025 : 2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ entry.ExternalAttributes = mode << 16;
+ using StreamWriter writer = new(entry.Open());
+ writer.Write(content);
+ }
+ }
+
+ internal string Write(string name, T value, JsonTypeInfo type)
+ {
+ string path = Path.Combine(Root, name);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, JsonSerializer.Serialize(value, type));
+ return path;
+ }
+
+ internal static T Read(string path, JsonTypeInfo type) =>
+ JsonSerializer.Deserialize(File.ReadAllText(path), type)!;
+
+ internal LookupEntry Lookup(bool success = true)
+ {
+ Invoke(success, "lookup", Write("submission.json", Submission, CacheJsonContext.Default.Submission),
+ "--cache", CacheDirectory, "--output", Path.Combine(Root, "lookup.json"),
+ "--queue-info", Write("queues.json", Queues, HelixJsonContext.Default.ListHelixQueue));
+ return success ? Read(Path.Combine(Root, "lookup.json"), CacheJsonContext.Default.LookupResult).Entries[0] : null!;
+ }
+
+ internal string CreateResults(ExecutionRecord execution, string jobId = FirstJob)
+ {
+ string results = Directory.CreateDirectory(Path.Combine(Root, "results")).FullName;
+ Write("results/result.json", new ResultReceipt
+ {
+ SchemaVersion = 1,
+ Completed = true,
+ InfrastructureSucceeded = true,
+ ExitCode = 0,
+ JobId = jobId,
+ WorkItemName = execution.Inputs.Name,
+ TargetQueue = execution.Inputs.TargetQueue,
+ ResultFiles = ["tests.xml"],
+ }, CacheJsonContext.Default.ResultReceipt);
+ File.WriteAllText(Path.Combine(results, "tests.xml"), Report);
+ return results;
+ }
+
+ internal void Store(ExecutionRecord execution, bool success = true) =>
+ Invoke(success, "store", Write("execution.json", execution, CacheJsonContext.Default.ExecutionRecord),
+ "--results", Path.Combine(Root, "results"), "--cache", CacheDirectory);
+
+ internal string Prepare(string name = "shadow")
+ {
+ string output = Path.Combine(Root, name);
+ ShadowValidation.Prepare(new Cache(CacheDirectory), output, Archive, Archive, Queues[0].QueueId, Archive, new LinuxEnvironment(Queues));
+ return output;
+ }
+
+ internal static LookupEntry ShadowLookup(string output) =>
+ Assert.Single(Read(Path.Combine(output, "lookup.json"), CacheJsonContext.Default.LookupResult).Entries);
+
+ internal string Invoke(bool success, params string[] arguments)
+ {
+ string? executable = Environment.GetEnvironmentVariable("HELIX_CACHE_EXECUTABLE");
+ if (executable is null)
+ {
+ DirectoryInfo? directory = new(Environment.CurrentDirectory);
+ while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "global.json")))
+ {
+ directory = directory.Parent;
+ }
+ Assert.NotNull(directory);
+ executable = Path.Combine(directory.FullName, "artifacts/tools/helix-cache/helix-cache");
+ }
+ ProcessStartInfo start = new(executable) { RedirectStandardOutput = true, RedirectStandardError = true };
+ foreach (string argument in arguments)
+ {
+ start.ArgumentList.Add(argument);
+ }
+ using Process process = Process.Start(start)!;
+ Task stdout = process.StandardOutput.ReadToEndAsync();
+ Task stderr = process.StandardError.ReadToEndAsync();
+ if (!process.WaitForExit(30_000))
+ {
+ process.Kill(entireProcessTree: true);
+ Assert.Fail("The native cache process timed out.");
+ }
+ string error = stderr.GetAwaiter().GetResult();
+ Assert.True(process.ExitCode == (success ? 0 : 1), error);
+ if (!success)
+ {
+ Assert.Contains("helix-cache:", error);
+ }
+ return stdout.GetAwaiter().GetResult();
+ }
+
+ public void Dispose() => Directory.Delete(Root, recursive: true);
+}
diff --git a/src/tools/helix-cache/tests/CacheTests.cs b/src/tools/helix-cache/tests/CacheTests.cs
new file mode 100644
index 00000000000000..63c38292d6d4d6
--- /dev/null
+++ b/src/tools/helix-cache/tests/CacheTests.cs
@@ -0,0 +1,548 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace HelixCache.Tests;
+
+public sealed class CacheTests
+{
+ [Fact]
+ public void RoundTripPreservesFrozenInputsAndResults()
+ {
+ using CacheFixture f = new();
+ LookupEntry miss = f.Lookup();
+ Assert.Equal(LookupStatus.Miss, miss.Status);
+ Assert.NotEqual(f.PayloadDirectory, miss.Execution.PayloadPath);
+ JsonObject fingerprint = JsonNode.Parse(File.ReadAllText(Path.Combine(f.Root, "lookup.json")))!
+ ["entries"]![0]!["execution"]!["inputs"]!["environmentFingerprint"]!.AsObject();
+ Assert.Equal(f.Queues[0].QueueId, (string?)fingerprint["queueId"]);
+ Assert.Equal("AMD64", (string?)fingerprint["architecture"]);
+ Assert.Equal("linux", (string?)fingerprint["operatingSystemGroup"]);
+ Assert.Contains("container", fingerprint.Select(p => p.Key));
+ f.CreateResults(miss.Execution);
+ f.Store(miss.Execution);
+ LookupEntry hit = f.Lookup();
+ Assert.Equal(LookupStatus.Hit, hit.Status);
+ Assert.Equal(miss.Execution.Key, hit.Execution.Key);
+ Assert.Equal(File.ReadAllBytes(Path.Combine(f.Root, "results/tests.xml")),
+ File.ReadAllBytes(Path.Combine(hit.ResultsPath!, "tests.xml")));
+ File.WriteAllText(Path.Combine(f.PayloadDirectory, "run.sh"), "changed");
+ Assert.Equal("#!/bin/sh\nexit 0\n", File.ReadAllText(Path.Combine(miss.Execution.PayloadPath, "run.sh")));
+ Assert.Equal(LookupStatus.Miss, f.Lookup().Status);
+ }
+
+ [Fact]
+ public void RepeatedLookupsOnOneCacheCaptureChangedInputs()
+ {
+ using CacheFixture f = new();
+ Cache cache = new(f.CacheDirectory);
+ LinuxEnvironment environment = new(f.Queues);
+ string before = Assert.Single(cache.Lookup(f.Submission, f.Root, environment).Entries).Execution.Key;
+ File.WriteAllText(Path.Combine(f.PayloadDirectory, "run.sh"), "changed");
+ string after = Assert.Single(cache.Lookup(f.Submission, f.Root, environment).Entries).Execution.Key;
+ Assert.NotEqual(before, after);
+ }
+
+ [Fact]
+ public void WholeSubmissionIsValidatedBeforeCapturingInputs()
+ {
+ using CacheFixture f = new();
+ f.Submission.WorkItems.Add(f.Item with { Name = "invalid", TimeoutSeconds = 0 });
+ Assert.Throws(() => new Cache(f.CacheDirectory).Lookup(f.Submission, f.Root, new LinuxEnvironment(f.Queues)));
+ Assert.False(Directory.Exists(f.CacheDirectory));
+ }
+
+ [Fact]
+ public void OpaqueProvisioningPreservesNullArrayValues()
+ {
+ using CacheFixture f = new();
+ f.Queues[0].Metadata["PreparedImage"] = JsonSerializer.Deserialize("""{"FutureField":[null,1]}""");
+ LookupEntry miss = f.Lookup();
+ f.CreateResults(miss.Execution);
+ f.Store(miss.Execution);
+ Assert.Equal(LookupStatus.Hit, f.Lookup().Status);
+ f.Queues[0].Metadata["PreparedImage"] = JsonSerializer.Deserialize("""{"FutureField":[null,2]}""");
+ Assert.NotEqual(miss.Execution.Key, f.Lookup().Execution.Key);
+ }
+
+ [Theory]
+ [InlineData("command")]
+ [InlineData("preCommands")]
+ [InlineData("postCommands")]
+ [InlineData("timeout")]
+ [InlineData("exitCode")]
+ [InlineData("environment")]
+ [InlineData("commandGenerator")]
+ public void ExecutionChangesInvalidateKey(string field)
+ {
+ using CacheFixture f = new();
+ string baseline = f.Lookup().Execution.Key;
+ f.Item = field switch
+ {
+ "command" => f.Item with { Command = "./run.sh other" },
+ "preCommands" => f.Item with { PreCommands = ["export STRESS=1"] },
+ "postCommands" => f.Item with { PostCommands = ["echo done"] },
+ "timeout" => f.Item with { TimeoutSeconds = 61 },
+ "exitCode" => f.Item with { ExpectedExitCode = 100 },
+ "environment" => f.Item with { Environment = new() { ["FIRST"] = "changed" } },
+ "commandGenerator" => f.Item with { CommandGeneratorDigest = new string('a', 64) },
+ _ => throw new InvalidOperationException(),
+ };
+ Assert.NotEqual(baseline, f.Lookup().Execution.Key);
+ }
+
+ [Theory]
+ [InlineData("GalleryImage", """{"Version":"2"}""")]
+ [InlineData("Artifacts", """[{"Id":"prepare","Parameters":{"version":"2"}}]""")]
+ [InlineData("PreparedImage", """{"Version":"new","FutureField":{"b":2,"a":1}}""")]
+ public void ImageAndProvisioningChangesInvalidateKey(string field, string value)
+ {
+ using CacheFixture f = new();
+ string baseline = f.Lookup().Execution.Key;
+ f.Queues[0].Metadata[field] = JsonSerializer.Deserialize(value);
+ Assert.NotEqual(baseline, f.Lookup().Execution.Key);
+ }
+
+ [Fact]
+ public void ObjectOrderingLiveQueueStateAndTimestampsDoNotChangeKey()
+ {
+ using CacheFixture f = new();
+ f.Queues[0].Metadata["PreparedImage"] = JsonSerializer.Deserialize("""{"b":{"z":2,"a":1},"a":null}""");
+ string baseline = f.Lookup().Execution.Key;
+ f.Queues[0].Metadata["PreparedImage"] = JsonSerializer.Deserialize("""{"a":null,"b":{"a":1,"z":2}}""");
+ f.Queues[0].Metadata["QueueDepth"] = JsonSerializer.SerializeToElement(300);
+ f.Queues[0].Metadata["IsInProtectedMode"] = JsonSerializer.SerializeToElement(true);
+ f.Item = f.Item with { Environment = new() { ["FIRST"] = "1", ["SECOND"] = "2" } };
+ File.SetLastWriteTimeUtc(Path.Combine(f.PayloadDirectory, "run.sh"), DateTime.UnixEpoch);
+ Assert.Equal(baseline, f.Lookup().Execution.Key);
+ }
+
+ [Fact]
+ public void RelocationDoesNotChangeKeyButModesAndEmptyDirectoriesDo()
+ {
+ using CacheFixture f = new();
+ string baseline = f.Lookup().Execution.Key;
+ string relocated = Path.Combine(f.Root, "relocated");
+ Directory.Move(f.PayloadDirectory, relocated);
+ f.Item = f.Item with { Payload = f.Item.Payload with { Path = relocated } };
+ Assert.Equal(baseline, f.Lookup().Execution.Key);
+ string script = Path.Combine(relocated, "run.sh");
+ UnixFileMode mode = File.GetUnixFileMode(script);
+ File.SetUnixFileMode(script, mode & ~UnixFileMode.UserExecute);
+ Assert.NotEqual(baseline, f.Lookup().Execution.Key);
+ File.SetUnixFileMode(script, mode);
+ Directory.CreateDirectory(Path.Combine(relocated, "empty"));
+ Assert.NotEqual(baseline, f.Lookup().Execution.Key);
+ }
+
+ [Fact]
+ public void QueuesAndPinnedContainersArePartitioned()
+ {
+ using CacheFixture f = new();
+ string target = "(Debian.Amd64)linux.test.open@registry/image:tag@sha256:" + new string('a', 64);
+ f.Item.Queues.Add(target);
+ LookupEntry first = f.Lookup();
+ LookupResult lookup = CacheFixture.Read(Path.Combine(f.Root, "lookup.json"), CacheJsonContext.Default.LookupResult);
+ Assert.Equal(2, lookup.Entries.Count);
+ Assert.NotEqual(first.Execution.Key, lookup.Entries[1].Execution.Key);
+ Assert.Equal(target[(target.IndexOf('@') + 1)..], lookup.Entries[1].Execution.Inputs.EnvironmentFingerprint.Container);
+ f.CreateResults(first.Execution);
+ f.Store(first.Execution);
+ f.Lookup();
+ lookup = CacheFixture.Read(Path.Combine(f.Root, "lookup.json"), CacheJsonContext.Default.LookupResult);
+ Assert.Equal([LookupStatus.Hit, LookupStatus.Miss], lookup.Entries.Select(e => e.Status));
+ }
+
+ [Theory]
+ [InlineData("linux.test.open@registry/image:latest")]
+ [InlineData("(alias)linux.test.open")]
+ public void UnpinnedOrMalformedContainerTargetsAreRejected(string target)
+ {
+ using CacheFixture f = new();
+ f.Item = f.Item with { Queues = [target] };
+ f.Lookup(success: false);
+ }
+
+ [Fact]
+ public void SharedPayloadBytesDestinationAndOverlayOrderAffectKey()
+ {
+ using CacheFixture f = new();
+ string baseline = f.Lookup().Execution.Key;
+ CacheFixture.WriteArchive(f.Archive, [("shared.txt", "changed payload", CacheFixture.ArchiveFileMode)]);
+ Assert.NotEqual(baseline, f.Lookup().Execution.Key);
+ CacheFixture.WriteArchive(f.Archive, [("shared.txt", "shared payload", CacheFixture.ArchiveFileMode)]);
+ f.Item.CorrelationPayloads[0] = f.Item.CorrelationPayloads[0] with { Destination = "other" };
+ Assert.NotEqual(baseline, f.Lookup().Execution.Key);
+ f.Item.CorrelationPayloads.Add(new Payload { Path = f.Archive, Destination = "second" });
+ string before = f.Lookup().Execution.Key;
+ f.Item.CorrelationPayloads.Reverse();
+ Assert.NotEqual(before, f.Lookup().Execution.Key);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void RepackedArchivesReuseFrozenBytesAndResults(bool workItemPayload)
+ {
+ using CacheFixture f = new();
+ if (workItemPayload)
+ {
+ f.Item = f.Item with { Payload = new Payload { Path = f.Archive } };
+ }
+ (string, string, int)[] entries =
+ [
+ ("run.sh", "#!/bin/sh\nexit 0\n", CacheFixture.ArchiveExecutableMode),
+ ("data.txt", "payload", CacheFixture.ArchiveFileMode),
+ ("empty/", "", CacheFixture.ArchiveDirectoryMode),
+ ("link", "data.txt", CacheFixture.ArchiveLinkMode),
+ ];
+ CacheFixture.WriteArchive(f.Archive, entries);
+ byte[] original = File.ReadAllBytes(f.Archive);
+ LookupEntry first = f.Lookup();
+ string frozen = first.Execution.CorrelationPayloadPaths[0];
+ Assert.Equal(original, File.ReadAllBytes(f.Archive));
+ f.CreateResults(first.Execution);
+ f.Store(first.Execution);
+
+ CacheFixture.WriteArchive(f.Archive, entries, repack: true);
+ Assert.False(original.SequenceEqual(File.ReadAllBytes(f.Archive)));
+ LookupEntry second = f.Lookup();
+ Assert.Equal(first.Execution.Key, second.Execution.Key);
+ Assert.Equal(LookupStatus.Hit, second.Status);
+ Assert.Equal(frozen, second.Execution.CorrelationPayloadPaths[0]);
+ using ZipArchive archive = ZipFile.OpenRead(frozen);
+ Assert.Equal(["data.txt", "empty/", "link", "run.sh"], archive.Entries.Select(e => e.FullName));
+ Assert.All(archive.Entries, e => Assert.Equal(new DateTime(2000, 1, 1), e.LastWriteTime.DateTime));
+ foreach ((string name, string content, int mode) in entries)
+ {
+ ZipArchiveEntry entry = archive.GetEntry(name)!;
+ Assert.Equal(mode << 16, entry.ExternalAttributes);
+ using StreamReader reader = new(entry.Open());
+ Assert.Equal(content, reader.ReadToEnd());
+ }
+ }
+
+ [Theory]
+ [InlineData("content")]
+ [InlineData("mode")]
+ [InlineData("emptyDirectory")]
+ [InlineData("linkTarget")]
+ public void ArchiveExecutionChangesInvalidateKey(string change)
+ {
+ using CacheFixture f = new();
+ (string, string, int)[] entries =
+ [
+ ("run.sh", "original", CacheFixture.ArchiveExecutableMode),
+ ("link", "run.sh", CacheFixture.ArchiveLinkMode),
+ ];
+ CacheFixture.WriteArchive(f.Archive, entries);
+ string before = f.Lookup().Execution.Key;
+ entries = change switch
+ {
+ "content" => [("run.sh", "changed", CacheFixture.ArchiveExecutableMode), entries[1]],
+ "mode" => [("run.sh", "original", CacheFixture.ArchiveFileMode), entries[1]],
+ "emptyDirectory" => [.. entries, ("empty/", "", CacheFixture.ArchiveDirectoryMode)],
+ "linkTarget" => [entries[0], ("link", "other", CacheFixture.ArchiveLinkMode)],
+ _ => throw new InvalidOperationException(),
+ };
+ CacheFixture.WriteArchive(f.Archive, entries);
+ Assert.NotEqual(before, f.Lookup().Execution.Key);
+ }
+
+ [Theory]
+ [InlineData("../outside", "other")]
+ [InlineData("/absolute", "other")]
+ [InlineData("a//b", "other")]
+ [InlineData("a\\b", "other")]
+ [InlineData("same", "same")]
+ [InlineData("same", "same/")]
+ [InlineData("parent", "parent/child")]
+ public void AmbiguousArchivePathsAreRejected(string first, string second)
+ {
+ using CacheFixture f = new();
+ CacheFixture.WriteArchive(f.Archive, [(first, "", CacheFixture.ArchiveFileMode), (second, "", CacheFixture.ArchiveFileMode)]);
+ f.Lookup(success: false);
+ }
+
+ [Fact]
+ public void InvalidArchivesAreRejected()
+ {
+ using CacheFixture f = new();
+ File.WriteAllText(f.Archive, "not a ZIP archive");
+ f.Lookup(success: false);
+ }
+
+ [Fact]
+ public void NativePrepareFingerprintsTheCommandGeneratorPackage()
+ {
+ using CacheFixture f = new();
+ string package = Path.Combine(f.Root, "sdk.nupkg");
+ File.WriteAllText(package, "command generator");
+ string first = Prepare("first");
+ string relocated = Path.Combine(f.Root, "relocated.nupkg");
+ File.Copy(package, relocated);
+ package = relocated;
+ Assert.Equal(first, Prepare("relocated"));
+ File.AppendAllText(package, " changed");
+ Assert.NotEqual(first, Prepare("changed"));
+
+ string Prepare(string name)
+ {
+ string output = Path.Combine(f.Root, name);
+ f.Invoke(true, "prepare", "--cache", f.CacheDirectory, "--output", output, "--payload", f.Archive,
+ "--runtime", f.Archive, "--queue", f.Queues[0].QueueId, "--helix-sdk", package,
+ "--queue-info", f.Write("queues.json", f.Queues, HelixJsonContext.Default.ListHelixQueue));
+ return CacheFixture.ShadowLookup(output).Execution.Key;
+ }
+ }
+
+ [Theory]
+ [InlineData("completed")]
+ [InlineData("infrastructure")]
+ [InlineData("exitCode")]
+ [InlineData("workItem")]
+ [InlineData("queue")]
+ [InlineData("job")]
+ [InlineData("empty")]
+ [InlineData("traversal")]
+ [InlineData("duplicate")]
+ public void UnsuccessfulReceiptsAreNotStored(string field)
+ {
+ using CacheFixture f = new();
+ ExecutionRecord execution = f.Lookup().Execution;
+ f.CreateResults(execution);
+ ResultReceipt receipt = CacheFixture.Read(Path.Combine(f.Root, "results/result.json"), CacheJsonContext.Default.ResultReceipt);
+ receipt = field switch
+ {
+ "completed" => receipt with { Completed = false },
+ "infrastructure" => receipt with { InfrastructureSucceeded = false },
+ "exitCode" => receipt with { ExitCode = 1 },
+ "workItem" => receipt with { WorkItemName = "other" },
+ "queue" => receipt with { TargetQueue = "other" },
+ "job" => receipt with { JobId = "not-a-guid" },
+ "empty" => receipt with { ResultFiles = [] },
+ "traversal" => receipt with { ResultFiles = ["../outside.xml"] },
+ "duplicate" => receipt with { ResultFiles = ["tests.xml", "tests.xml"] },
+ _ => throw new InvalidOperationException(),
+ };
+ f.Write("results/result.json", receipt, CacheJsonContext.Default.ResultReceipt);
+ f.Store(execution, success: false);
+ Assert.Equal(LookupStatus.Miss, f.Lookup().Status);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("""""")]
+ [InlineData("""""")]
+ [InlineData("""""")]
+ [InlineData("""""")]
+ [InlineData("""]>&x;""")]
+ [InlineData("""""")]
+ [InlineData("""""")]
+ [InlineData("""""")]
+ public void ReportsMustBeCompleteNonemptyAndPassing(string report)
+ {
+ using CacheFixture f = new();
+ ExecutionRecord execution = f.Lookup().Execution;
+ f.CreateResults(execution);
+ File.WriteAllText(Path.Combine(f.Root, "results/tests.xml"), report);
+ f.Store(execution, success: false);
+ Assert.Equal(LookupStatus.Miss, f.Lookup().Status);
+ }
+
+ [Fact]
+ public void CorruptResultsAreAnErrorRatherThanAHitOrMiss()
+ {
+ using CacheFixture f = new();
+ ExecutionRecord execution = f.Lookup().Execution;
+ f.CreateResults(execution);
+ f.Store(execution);
+ string results = f.Lookup().ResultsPath!;
+ File.WriteAllText(Path.Combine(results, "tests.xml"), "corrupted");
+ f.Lookup(success: false);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void ForgedKeysAndModifiedSnapshotsCannotBeStored(bool forgeKey)
+ {
+ using CacheFixture f = new();
+ ExecutionRecord execution = f.Lookup().Execution;
+ f.CreateResults(execution);
+ if (forgeKey)
+ {
+ execution = execution with { Key = new string('0', 64) };
+ }
+ else
+ {
+ File.WriteAllText(Path.Combine(execution.PayloadPath, "run.sh"), "changed");
+ }
+ f.Store(execution, success: false);
+ }
+
+ [Fact]
+ public void ResultsCannotContainTheirStagingDirectory()
+ {
+ using CacheFixture f = new();
+ ExecutionRecord execution = f.Lookup().Execution;
+ f.Invoke(false, "store", f.Write("execution.json", execution, CacheJsonContext.Default.ExecutionRecord),
+ "--results", Path.Combine(f.CacheDirectory, "tmp"), "--cache", f.CacheDirectory);
+ Assert.Empty(Directory.EnumerateFileSystemEntries(Path.Combine(f.CacheDirectory, "tmp")));
+ Assert.Equal(LookupStatus.Miss, f.Lookup().Status);
+ }
+
+ [Fact]
+ public async Task DuplicateStoresAreAtomicAndIdempotent()
+ {
+ using CacheFixture f = new();
+ ExecutionRecord execution = f.Lookup().Execution;
+ f.CreateResults(execution);
+ string record = f.Write("execution.json", execution, CacheJsonContext.Default.ExecutionRecord);
+ Task[] operations = Enumerable.Range(0, 2).Select(_ => Task.Run(() => f.Invoke(true,
+ "store", record, "--results", Path.Combine(f.Root, "results"), "--cache", f.CacheDirectory))).ToArray();
+ await Task.WhenAll(operations);
+ Assert.Equal(LookupStatus.Hit, f.Lookup().Status);
+ Assert.Empty(Directory.EnumerateFileSystemEntries(Path.Combine(f.CacheDirectory, "tmp")));
+ }
+
+ [Theory]
+ [InlineData("architecture")]
+ [InlineData("os")]
+ [InlineData("availability")]
+ public void UnsupportedQueuesAreRejected(string field)
+ {
+ using CacheFixture f = new();
+ f.Queues[0] = field switch
+ {
+ "architecture" => f.Queues[0] with { Architecture = "ARM64" },
+ "os" => f.Queues[0] with { OperatingSystemGroup = "windows" },
+ "availability" => f.Queues[0] with { IsAvailable = false },
+ _ => throw new InvalidOperationException(),
+ };
+ f.Lookup(success: false);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void LinksAndNestedCachesAreRejected(bool link)
+ {
+ using CacheFixture f = new();
+ if (link)
+ {
+ File.CreateSymbolicLink(Path.Combine(f.PayloadDirectory, "link"), f.Archive);
+ }
+ else
+ {
+ f.CacheDirectory = Path.Combine(f.PayloadDirectory, "cache");
+ }
+ f.Lookup(success: false);
+ }
+
+ [Theory]
+ [InlineData("version")]
+ [InlineData("empty")]
+ [InlineData("nullItem")]
+ [InlineData("unknown")]
+ [InlineData("missing")]
+ [InlineData("nullCommand")]
+ [InlineData("timeout")]
+ [InlineData("nullCorrelation")]
+ [InlineData("commandGenerator")]
+ public void InvalidManifestsAreRejected(string mutation)
+ {
+ using CacheFixture f = new();
+ JsonNode manifest = JsonNode.Parse(JsonSerializer.Serialize(f.Submission, CacheJsonContext.Default.Submission))!;
+ JsonNode item = manifest["workItems"]![0]!;
+ switch (mutation)
+ {
+ case "version": manifest["schemaVersion"] = 2; break;
+ case "empty": manifest["workItems"] = new JsonArray(); break;
+ case "nullItem": manifest["workItems"]![0] = null; break;
+ case "unknown": manifest["unrecognized"] = true; break;
+ case "missing": item.AsObject().Remove("expectedExitCode"); break;
+ case "nullCommand": item["command"] = null; break;
+ case "timeout": item["timeoutSeconds"] = 0; break;
+ case "nullCorrelation": item["correlationPayloads"]![0] = null; break;
+ case "commandGenerator": item["commandGeneratorDigest"] = "invalid"; break;
+ default: throw new InvalidOperationException();
+ }
+ string path = Path.Combine(f.Root, "invalid.json");
+ File.WriteAllText(path, manifest.ToJsonString());
+ f.Invoke(false, "lookup", path, "--cache", f.CacheDirectory, "--output", Path.Combine(f.Root, "lookup.json"),
+ "--queue-info", f.Write("queues.json", f.Queues, HelixJsonContext.Default.ListHelixQueue));
+ }
+
+ [Theory]
+ [InlineData("""{"schemaVersion":1,"schemaVersion":1,"workItems":[]}""")]
+ [InlineData("""{"schemaVersion":1,"workItems":[],"workItems":[]}""")]
+ public void DuplicateJsonPropertiesAreRejected(string manifest)
+ {
+ using CacheFixture f = new();
+ string path = Path.Combine(f.Root, "duplicate.json");
+ File.WriteAllText(path, manifest);
+ f.Invoke(false, "lookup", path, "--cache", f.CacheDirectory, "--output", Path.Combine(f.Root, "lookup.json"),
+ "--queue-info", f.Write("queues.json", f.Queues, HelixJsonContext.Default.ListHelixQueue));
+ }
+
+ [Fact]
+ public void CliRejectsMissingAndDuplicateOptions()
+ {
+ using CacheFixture f = new();
+ f.Invoke(false, "store", "missing", "--cache", f.CacheDirectory);
+ f.Invoke(false, "lookup", "missing", "--cache", f.CacheDirectory, "--cache", f.CacheDirectory);
+ f.Invoke(false, "collect", "--cache", f.CacheDirectory, "--output", f.Root, "--wait-seconds", "0");
+ }
+
+ [Theory]
+ [InlineData("--help")]
+ [InlineData("lookup --help")]
+ [InlineData("store -h")]
+ [InlineData("prepare --help")]
+ [InlineData("collect --help")]
+ public void HelpDoesNotRequireInputsOrCreateCache(string arguments)
+ {
+ using CacheFixture f = new();
+ Assert.Contains("Usage: helix-cache", f.Invoke(true, arguments.Split(' ')));
+ Assert.False(Directory.Exists(f.CacheDirectory));
+ }
+
+ [Theory]
+ [InlineData("lookup", "Missing manifest")]
+ [InlineData("lookup --cache cache --output output", "Missing manifest")]
+ [InlineData("lookup missing.json --cache cache --output output --unknown value", "Unknown option '--unknown'")]
+ [InlineData("lookup missing.json --cache cache --cache cache", "Duplicate option '--cache'")]
+ [InlineData("store missing.json --cache cache", "Missing required option '--results'")]
+ [InlineData("collect --cache cache --output output --wait-seconds -1", "--wait-seconds")]
+ [InlineData("collect --cache --output output", "Expected an option and value")]
+ public void CommandLineErrorsIdentifyTheInvalidArgument(string arguments, string diagnostic)
+ {
+ InvalidDataException error = Assert.Throws(() => CommandLine.Parse(arguments.Split(' ')));
+ Assert.Contains(diagnostic, error.Message);
+ }
+
+ [Fact]
+ public void NativePrepareWritesFrozenSubmissionProperties()
+ {
+ using CacheFixture f = new();
+ string output = Path.Combine(f.Root, "native-shadow");
+ f.Invoke(true, "prepare", "--cache", f.CacheDirectory, "--output", output, "--payload", f.Archive,
+ "--runtime", f.Archive, "--queue", f.Queues[0].QueueId, "--helix-sdk", f.Archive,
+ "--queue-info", f.Write("queues.json", f.Queues, HelixJsonContext.Default.ListHelixQueue));
+ LookupEntry entry = CacheFixture.ShadowLookup(output);
+ Assert.Equal(LookupStatus.Miss, entry.Status);
+ Assert.NotEqual(f.Archive, entry.Execution.PayloadPath);
+ Assert.Equal(ShadowValidation.Command, entry.Execution.Inputs.Command);
+ Assert.Contains("%24", File.ReadAllText(Path.Combine(output, "submission.props")));
+ }
+}
diff --git a/src/tools/helix-cache/tests/HelixCache.Tests.csproj b/src/tools/helix-cache/tests/HelixCache.Tests.csproj
new file mode 100644
index 00000000000000..a521f2c3ab8d57
--- /dev/null
+++ b/src/tools/helix-cache/tests/HelixCache.Tests.csproj
@@ -0,0 +1,10 @@
+
+
+ $(NetCoreAppToolCurrent)
+ enable
+ false
+
+
+
+
+
diff --git a/src/tools/helix-cache/tests/ShadowTests.cs b/src/tools/helix-cache/tests/ShadowTests.cs
new file mode 100644
index 00000000000000..2cfe4734d90198
--- /dev/null
+++ b/src/tools/helix-cache/tests/ShadowTests.cs
@@ -0,0 +1,303 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace HelixCache.Tests;
+
+public sealed class ShadowTests
+{
+ [Fact]
+ public async Task ColdStoreAndRestoredEntriesCompareActualOutcomes()
+ {
+ using CacheFixture f = new();
+ string first = f.Prepare();
+ await Collect(f, first, CacheFixture.FirstJob);
+ ShadowSummary cold = CacheFixture.Read(Path.Combine(first, "summary.json"), CacheJsonContext.Default.ShadowSummary);
+ Assert.Equal(LookupStatus.Miss, cold.Lookup);
+ Assert.Equal(2, cold.Tests);
+ string restored = Path.Combine(f.Root, "restored-cache");
+ Snapshot.Copy(Path.Combine(f.CacheDirectory, "entries"), Path.Combine(restored, "entries"));
+ Directory.Move(f.CacheDirectory, Path.Combine(f.Root, "previous-cache"));
+ f.CacheDirectory = restored;
+ string second = f.Prepare("second");
+ Assert.Equal(LookupStatus.Hit, CacheFixture.ShadowLookup(second).Status);
+ string reordered = """
+
+
+
+ """;
+ await Collect(f, second, CacheFixture.SecondJob, report: reordered);
+ ShadowSummary summary = CacheFixture.Read(Path.Combine(second, "summary.json"), CacheJsonContext.Default.ShadowSummary);
+ Assert.Equal(OutcomeComparison.Match, summary.Comparison);
+ Assert.Equal(CacheFixture.FirstJob, summary.CachedJobId);
+ Assert.Equal(CacheFixture.SecondJob, summary.JobId);
+ Assert.Equal(cold.Key, summary.Key);
+ }
+
+ [Theory]
+ [InlineData("name")]
+ [InlineData("skipReason")]
+ [InlineData("duplicate")]
+ public async Task MismatchesDoNotOverwriteCachedProvenance(string change)
+ {
+ using CacheFixture f = new();
+ string first = f.Prepare();
+ await Collect(f, first, CacheFixture.FirstJob);
+ string second = f.Prepare("second");
+ string changed = change switch
+ {
+ "name" => CacheFixture.Report.Replace("name=\"one\"", "name=\"different-test\"", StringComparison.Ordinal),
+ "skipReason" => CacheFixture.Report.Replace("",
+ "different", StringComparison.Ordinal),
+ "duplicate" => CacheFixture.Report.Replace("total=\"2\" passed=\"1\"", "total=\"3\" passed=\"2\"", StringComparison.Ordinal)
+ .Replace("", "", StringComparison.Ordinal),
+ _ => throw new InvalidOperationException(),
+ };
+ InvalidDataException error = await Assert.ThrowsAsync(
+ () => Collect(f, second, CacheFixture.SecondJob, report: changed));
+ Assert.Contains("outcomes differ", error.Message);
+ ShadowSummary summary = CacheFixture.Read(Path.Combine(second, "summary.json"), CacheJsonContext.Default.ShadowSummary);
+ Assert.Equal(OutcomeComparison.Mismatch, summary.Comparison);
+ Assert.False(summary.Stored);
+ Assert.Equal(CacheFixture.FirstJob, summary.CachedJobId);
+ string cached = CacheFixture.ShadowLookup(second).ResultsPath!;
+ Assert.Equal(CacheFixture.FirstJob,
+ CacheFixture.Read(Path.Combine(cached, "result.json"), CacheJsonContext.Default.ResultReceipt).JobId);
+ }
+
+ [Theory]
+ [InlineData("failed")]
+ [InlineData("failureReason")]
+ [InlineData("errors")]
+ [InlineData("exitCode")]
+ [InlineData("missingExitCode")]
+ public async Task UnsuccessfulRemoteCompletionsAreNotAdmitted(string failure)
+ {
+ using CacheFixture f = new();
+ string output = f.Prepare();
+ HelixWorkItem item = PassedItem(CacheFixture.FirstJob);
+ item = failure switch
+ {
+ "failed" => item with { State = "Failed" },
+ "failureReason" => item with { FailureReason = new HelixFailure() },
+ "errors" => item with { Errors = [new HelixFailure()] },
+ "exitCode" => item with { ExitCode = 1 },
+ "missingExitCode" => item with { ExitCode = null },
+ _ => throw new InvalidOperationException(),
+ };
+ InvalidDataException error = await Assert.ThrowsAsync(
+ () => Collect(f, output, CacheFixture.FirstJob, item: item));
+ Assert.Contains("did not complete", error.Message);
+ Assert.False(Directory.Exists(Path.Combine(output, "results")));
+ Assert.Empty(Directory.EnumerateFiles(f.CacheDirectory, "entry.json", SearchOption.AllDirectories));
+ }
+
+ [Theory]
+ [InlineData("linux.test.open.rt", "LINUX.TEST.OPEN", false, true)]
+ [InlineData("linux.test.open", null, false, true)]
+ [InlineData("linux.test.open", "", false, true)]
+ [InlineData("linux.test.open", "other", false, false)]
+ [InlineData("other", "other", false, false)]
+ [InlineData("other", null, false, false)]
+ [InlineData("linux.test.open.rt", "linux.test.open", true, false)]
+ public async Task QueueAliasesAndJobIdentityAreValidatedAndRetained(
+ string queueId, string? alias, bool wrongJob, bool accepted)
+ {
+ using CacheFixture f = new();
+ string output = f.Prepare();
+ HelixJob job = new()
+ {
+ Name = wrongJob ? CacheFixture.SecondJob : CacheFixture.FirstJob,
+ QueueId = queueId,
+ QueueAlias = alias,
+ };
+ if (!accepted)
+ {
+ InvalidDataException error = await Assert.ThrowsAsync(
+ () => Collect(f, output, CacheFixture.FirstJob, job: job));
+ Assert.Contains("job or target queue", error.Message);
+ Assert.False(Directory.Exists(Path.Combine(output, "results")));
+ Assert.Empty(Directory.EnumerateFiles(f.CacheDirectory, "entry.json", SearchOption.AllDirectories));
+ return;
+ }
+ await Collect(f, output, CacheFixture.FirstJob, job: job);
+ string entry = Directory.GetFiles(f.CacheDirectory, "entry.json", SearchOption.AllDirectories)[0];
+ string results = Path.Combine(Path.GetDirectoryName(entry)!, "results");
+ ExecutionProvenance provenance = CacheFixture.Read(Path.Combine(results, "provenance.json"), CacheJsonContext.Default.ExecutionProvenance);
+ Assert.Equal(queueId, provenance.ResolvedQueueId);
+ Assert.Equal(alias, provenance.QueueAlias);
+ Assert.Equal("linux.test.open",
+ CacheFixture.Read(Path.Combine(results, "result.json"), CacheJsonContext.Default.ResultReceipt).TargetQueue);
+ }
+
+ [Fact]
+ public void MissingQueueAliasIsSupportedByTypedDeserialization()
+ {
+ HelixJob job = JsonSerializer.Deserialize("""{"Name":"job","QueueId":"queue"}""", HelixJsonContext.Default.HelixJob)!;
+ Assert.Null(job.QueueAlias);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(2)]
+ public async Task ExactlyOneUploadedReportIsRequired(int count)
+ {
+ using CacheFixture f = new();
+ string output = f.Prepare();
+ List files = [];
+ for (int i = 0; i < count; i++)
+ {
+ files.Add(new HelixFile { Name = "testResults.xml", Link = "https://example.invalid/report" });
+ }
+ InvalidDataException error = await Assert.ThrowsAsync(
+ () => Collect(f, output, CacheFixture.FirstJob, files: files));
+ Assert.Contains("exactly one", error.Message);
+ Assert.False(Directory.Exists(Path.Combine(output, "results")));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("""""")]
+ public async Task UploadedXmlMustPassNativeAdmission(string report)
+ {
+ using CacheFixture f = new();
+ string output = f.Prepare();
+ await Assert.ThrowsAsync(() => Collect(f, output, CacheFixture.FirstJob, report: report));
+ Assert.Empty(Directory.EnumerateFiles(f.CacheDirectory, "entry.json", SearchOption.AllDirectories));
+ }
+
+ [Fact]
+ public async Task CorruptionBetweenPrepareAndCollectIsNotOverwritten()
+ {
+ using CacheFixture f = new();
+ string first = f.Prepare();
+ await Collect(f, first, CacheFixture.FirstJob);
+ string second = f.Prepare("second");
+ string cachedReport = Path.Combine(CacheFixture.ShadowLookup(second).ResultsPath!, "testResults.xml");
+ File.WriteAllText(cachedReport, "corrupt");
+ await Assert.ThrowsAsync(() => Collect(f, second, CacheFixture.SecondJob));
+ Assert.False(Directory.Exists(Path.Combine(second, "results")));
+ Assert.Equal("corrupt", File.ReadAllText(cachedReport));
+ }
+
+ [Theory]
+ [InlineData("Canceled", false, false)]
+ [InlineData("Passed", true, false)]
+ [InlineData("Passed", false, true)]
+ public async Task PollingRejectsUnknownStatesAndWrongIdentity(string state, bool wrongJob, bool wrongName)
+ {
+ HelixWorkItem item = PassedItem(CacheFixture.FirstJob) with
+ {
+ State = state,
+ Job = wrongJob ? CacheFixture.SecondJob : CacheFixture.FirstJob,
+ Name = wrongName ? "other" : ShadowValidation.WorkItem,
+ };
+ using HttpClient client = Client(new HelixJob { Name = CacheFixture.FirstJob, QueueId = "queue" }, item);
+ await Assert.ThrowsAsync(() =>
+ new HelixClient(client).WaitForCompletionAsync(CacheFixture.FirstJob, ShadowValidation.WorkItem, TimeSpan.FromMinutes(1)));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task PollingHonorsTimeoutAndCallerCancellation(bool cancelCaller)
+ {
+ using HttpClient client = new(new Stub(async (_, cancellation) =>
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellation);
+ throw new InvalidOperationException();
+ }));
+ using CancellationTokenSource cancellation = new();
+ if (cancelCaller)
+ {
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() => new HelixClient(client).WaitForCompletionAsync(
+ CacheFixture.FirstJob, ShadowValidation.WorkItem, TimeSpan.FromMinutes(1), cancellation.Token));
+ }
+ else
+ {
+ await Assert.ThrowsAsync(() => new HelixClient(client).WaitForCompletionAsync(
+ CacheFixture.FirstJob, ShadowValidation.WorkItem, TimeSpan.FromMilliseconds(20)));
+ }
+ }
+
+ [Fact]
+ public async Task PollingWaitsForTerminalCompletion()
+ {
+ int requests = 0;
+ using HttpClient client = new(new Stub((_, _) =>
+ {
+ HelixWorkItem item = PassedItem(CacheFixture.FirstJob) with { State = ++requests == 1 ? "Running" : "Finished" };
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = JsonContent.Create(item, HelixJsonContext.Default.HelixWorkItem),
+ });
+ }));
+ HelixWorkItem completed = await new HelixClient(client).WaitForCompletionAsync(
+ CacheFixture.FirstJob, ShadowValidation.WorkItem, TimeSpan.FromMinutes(1));
+ Assert.Equal("Finished", completed.State);
+ Assert.Equal(2, requests);
+ }
+
+ [Fact]
+ public async Task TransportTimeoutIsNotReportedAsTheJobDeadline()
+ {
+ using HttpClient client = new(new Stub((_, _) => throw new TaskCanceledException("HTTP request timed out")));
+ TaskCanceledException error = await Assert.ThrowsAsync(() =>
+ new HelixClient(client).WaitForCompletionAsync(CacheFixture.FirstJob, ShadowValidation.WorkItem, TimeSpan.FromMinutes(1)));
+ Assert.Equal("HTTP request timed out", error.Message);
+ }
+
+ private static async Task Collect(CacheFixture f, string output, string jobId,
+ HelixJob? job = null, HelixWorkItem? item = null, List? files = null, string? report = null)
+ {
+ File.WriteAllText(Path.Combine(output, "job-id.txt"), jobId + "\n");
+ using HttpClient client = Client(job ?? new HelixJob { Name = jobId, QueueId = f.Queues[0].QueueId },
+ item ?? PassedItem(jobId), files, report);
+ await ShadowValidation.CollectAsync(new Cache(f.CacheDirectory), output, TimeSpan.FromMinutes(1), new HelixClient(client));
+ }
+
+ private static HelixWorkItem PassedItem(string jobId) => new()
+ {
+ Job = jobId,
+ Name = ShadowValidation.WorkItem,
+ State = "Passed",
+ ExitCode = 0,
+ MachineName = "test-worker",
+ };
+
+ private static HttpClient Client(HelixJob job, HelixWorkItem item, List? files = null, string? report = null) =>
+ new(new Stub((request, _) =>
+ {
+ HttpContent content = request.RequestUri!.AbsolutePath switch
+ {
+ string path when path.EndsWith("/details", StringComparison.Ordinal) =>
+ JsonContent.Create(job, HelixJsonContext.Default.HelixJob),
+ string path when path.EndsWith("/files", StringComparison.Ordinal) =>
+ JsonContent.Create(files ?? [new HelixFile { Name = "testResults.xml", Link = "https://example.invalid/report" }],
+ HelixJsonContext.Default.ListHelixFile),
+ "/report" => new StringContent(report ?? CacheFixture.Report, Encoding.UTF8, "application/xml"),
+ string path when path.Contains("/workitems/", StringComparison.Ordinal) =>
+ JsonContent.Create(item, HelixJsonContext.Default.HelixWorkItem),
+ _ => throw new InvalidOperationException($"Unexpected HTTP request: {request.RequestUri}"),
+ };
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content });
+ }));
+
+ private sealed class Stub(Func> send) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
+ send(request, cancellationToken);
+ }
+}