Move client: Refused only for Heimdall's coded move refusals, any pipeline exception is Unknown, ids end at \z

An uncoded 4xx (gateway 404, 408, 410, unknown code) is Unknown: Refused wipes sealed proofs. Unrecognized leaves the refusal enum.
The catch lists no types: only the caller's cancellation propagates. moveId and reversesMoveId patterns use \z in client and fake,
so a trailing line feed is refused as Heimdall's ajv refuses it; 64, 65 and newline are pinned.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-14 03:50:42 +00:00
commit 1ffb8886c0
4 changed files with 98 additions and 26 deletions

View file

@ -33,7 +33,7 @@ public enum HeimdallProofTier
public sealed record HeimdallMoveReceipt(
string MoveId, string AccountId, IReadOnlyList<string> MovedIdentityIds, HeimdallProofTier ProofFrom, HeimdallProofTier? ProofTo, string? ReversesMoveId);
/// <summary>Heimdall's fixed refusal codes for a move, plus the two local classes: a 400, and a 4xx this client does not know.</summary>
/// <summary>Heimdall's fixed refusal codes for a move, plus the one local class: Heimdall's schema 400.</summary>
public enum HeimdallMoveRefusal
{
AppAuthRequired,
@ -46,8 +46,7 @@ public enum HeimdallMoveRefusal
AlreadyMoved,
ProviderConflict,
IdentityNotFound,
RequestInvalid,
Unrecognized
RequestInvalid
}
public enum HeimdallProofSide
@ -90,7 +89,8 @@ public sealed partial class HeimdallIdentityMoveClient(
private const int MaxReceiptBytes = 64 * 1024;
private const int MaxProofLength = 8192;
[GeneratedRegex("^[A-Za-z0-9_-]{16,64}$")]
// \z, not $: $ also matches before a final line feed, which Heimdall's schema pattern refuses with a 400.
[GeneratedRegex("^[A-Za-z0-9_-]{16,64}\\z")]
private static partial Regex MoveIdShape();
public async Task<HeimdallMoveOutcome> MoveAsync(HeimdallMoveRequest request, CancellationToken cancellationToken)
@ -128,19 +128,28 @@ public sealed partial class HeimdallIdentityMoveClient(
return new HeimdallMoveOutcome.Unknown($"HTTP {status}");
}
var refusal = await HeimdallRefusal.ReadAsync(response, token);
return status == 400 ? new HeimdallMoveOutcome.Refused(HeimdallMoveRefusal.RequestInvalid) : Refused(refusal);
// Fastify's schema 400 answers before the route runs, so it carries no Heimdall code and applied nothing.
if (status == 400)
{
return new HeimdallMoveOutcome.Refused(HeimdallMoveRefusal.RequestInvalid);
}
return Refused(await HeimdallRefusal.ReadAsync(response, token), status);
}
catch (Exception error) when (error is HttpRequestException or IOException or JsonException or NotSupportedException or OperationCanceledException
&& !cancellationToken.IsCancellationRequested)
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
// Whatever the pipeline throws after the request may have gone out is not a refusal; only the caller's own cancellation propagates.
return new HeimdallMoveOutcome.Unknown("transport");
}
}
private static HeimdallMoveOutcome Refused(HeimdallRefusalBody? refusal)
/// <summary>
/// Refused only for the coded refusals Heimdall's move route emits (4fe367a moveIdentitiesRoute). A 4xx without one of those codes
/// (a gateway's HTML 404, a 408, a code a later Heimdall adds) proves nothing was applied, so it is Unknown. move_id_conflict is
/// Unknown too: a committed move answers it when its proofs no longer verify.
/// </summary>
private static HeimdallMoveOutcome Refused(HeimdallRefusalBody? refusal, int status)
{
// Not a refusal: a committed move answers 409 move_id_conflict when its proofs no longer verify, so it proves nothing about the move.
if (refusal?.Code == "move_id_conflict")
{
return new HeimdallMoveOutcome.Unknown("move_id_conflict");
@ -158,18 +167,23 @@ public sealed partial class HeimdallIdentityMoveClient(
"already_moved" => HeimdallMoveRefusal.AlreadyMoved,
"provider_conflict" => HeimdallMoveRefusal.ProviderConflict,
"identity_not_found" => HeimdallMoveRefusal.IdentityNotFound,
_ => HeimdallMoveRefusal.Unrecognized
_ => (HeimdallMoveRefusal?)null
};
return code switch
if (code is not { } known)
{
HeimdallMoveRefusal.ProofInvalid or HeimdallMoveRefusal.ProofStale => new HeimdallMoveOutcome.Refused(code, Side: refusal?.Proof switch
return new HeimdallMoveOutcome.Unknown($"HTTP {status} without a Heimdall refusal code");
}
return known switch
{
HeimdallMoveRefusal.ProofInvalid or HeimdallMoveRefusal.ProofStale => new HeimdallMoveOutcome.Refused(known, Side: refusal?.Proof switch
{
"from" => HeimdallProofSide.From,
"to" => HeimdallProofSide.To,
_ => null
}),
HeimdallMoveRefusal.ProviderConflict => new HeimdallMoveOutcome.Refused(code, Provider: refusal?.Provider),
_ => new HeimdallMoveOutcome.Refused(code)
HeimdallMoveRefusal.ProviderConflict => new HeimdallMoveOutcome.Refused(known, Provider: refusal?.Provider),
_ => new HeimdallMoveOutcome.Refused(known)
};
}

View file

@ -63,6 +63,9 @@ public sealed class FakeHeimdallMoverTests
Move(Id(), Enumerable.Repeat("p", 9).ToArray(), "x"),
Move(Id(), [new string('p', 8193)], "x"),
Move("short", [strong()], "x"),
Move(Id() + "\n", [strong()], "x"),
Move(new string('a', 65), [strong()], "x"),
Move(Id(), [strong()], reverses: Id() + "\n"),
"""{"moveId":"move-0123456789abcdef","fromProofs":"p","toProof":"x"}""",
"[]"
};

View file

@ -292,23 +292,70 @@ public sealed class HeimdallIdentityMoveClientTests
}
[Fact]
public async Task A_missing_route_is_Unrecognized_and_a_400_is_RequestInvalid_both_definitive_refusals()
public async Task A_400_is_RequestInvalid_a_definitive_refusal_that_moves_nothing()
{
using var host = new HeimdallHost();
var moves = host.Heimdall.Moves;
var (source, _) = AccountWith(moves, "discord");
var (destination, _) = AccountWith(moves, "twitch");
var request = Forward(moves, source, destination);
moves.MoveStatus = HttpStatusCode.NotFound;
Assert.Equal(new Refused(HeimdallMoveRefusal.Unrecognized), await Move(host, request));
moves.MoveStatus = HttpStatusCode.BadRequest;
Assert.Equal(new Refused(HeimdallMoveRefusal.RequestInvalid), await Move(host, request));
moves.MoveStatus = (HttpStatusCode)418;
Assert.Equal(new Refused(HeimdallMoveRefusal.Unrecognized), await Move(host, request));
Assert.Equal(new Refused(HeimdallMoveRefusal.RequestInvalid), await Move(host, Forward(moves, source, destination)));
Assert.Empty(moves.Stored);
}
[Theory]
[InlineData(HttpStatusCode.NotFound, "<html><body>404 Not Found</body></html>")]
[InlineData(HttpStatusCode.NotFound, "")]
[InlineData(HttpStatusCode.RequestTimeout, "")]
[InlineData(HttpStatusCode.RequestTimeout, """{"error":"request_timeout"}""")]
[InlineData(HttpStatusCode.Gone, "")]
[InlineData(HttpStatusCode.UnavailableForLegalReasons, "")]
[InlineData(HttpStatusCode.Unauthorized, """{"error":"weird_code"}""")]
[InlineData(HttpStatusCode.Forbidden, """{"message":"no error field"}""")]
[InlineData((HttpStatusCode)418, """{"error":"app_auth_required_sort_of"}""")]
public async Task A_4xx_without_one_of_Heimdalls_move_codes_is_Unknown_because_it_proves_nothing_was_applied_no_more_than_a_gateway_does(
HttpStatusCode status, string body)
{
Assert.IsType<Unknown>(await Answered(body, Plain, status));
}
[Fact]
public async Task A_404_with_no_body_from_the_route_is_Unknown_not_identity_not_found()
{
using var host = new HeimdallHost();
var moves = host.Heimdall.Moves;
var (source, _) = AccountWith(moves, "discord");
var (destination, _) = AccountWith(moves, "twitch");
moves.MoveStatus = HttpStatusCode.NotFound;
Assert.IsType<Unknown>(await Move(host, Forward(moves, source, destination)));
}
[Fact]
public async Task An_exception_of_any_kind_from_the_pipeline_is_Unknown_and_only_the_callers_cancellation_propagates()
{
Exception[] thrown =
[
new InvalidOperationException("handler"), new ObjectDisposedException("connection"),
new System.Security.Authentication.AuthenticationException("tls"), new InvalidDataException("zip"), new UriFormatException("uri"),
new HttpRequestException("down"), new IOException("reset"), new TaskCanceledException("deadline")
];
foreach (var error in thrown)
{
var client = new HeimdallIdentityMoveClient(
new HttpClient(new Throwing(error)) { BaseAddress = new Uri("https://heimdall.test") },
Options.Create(new HeimdallOptions()), NullLogger<HeimdallIdentityMoveClient>.Instance);
Assert.IsType<Unknown>(await client.MoveAsync(Plain, CancellationToken.None));
}
}
private sealed class Throwing(Exception error) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => throw error;
}
// ---- unknown outcomes ----
[Theory]
@ -475,7 +522,13 @@ public sealed class HeimdallIdentityMoveClientTests
new(good, proofs, IdentityIds: ["i"], ReversesMoveId: NewMoveId()),
new(good, proofs, "t", []),
new(good, proofs, "t", Enumerable.Repeat("i", 33).ToArray()),
new(good, [""], "t")
new(good, [""], "t"),
new(good + "\n", proofs, "t"),
new(good, proofs, ReversesMoveId: good + "\n"),
new("move-0123456789abcdef\n", proofs, "t"),
new(new string('a', 65), proofs, "t"),
new(new string('a', 15), proofs, "t"),
new(good, proofs, ReversesMoveId: new string('a', 65))
};
foreach (var request in broken)
@ -543,7 +596,9 @@ public sealed class HeimdallIdentityMoveClientTests
var (destination, _) = AccountWith(moves, "twitch");
var proofs = Enumerable.Range(0, 8).Select(_ => moves.Issue(source, stepUpMethod: Passkey)).ToArray();
var receipt = Receipt(await Move(host, new(NewMoveId(), proofs, moves.Issue(destination), ids)));
var longest = new string('m', 64);
var receipt = Receipt(await Move(host, new(longest, proofs, moves.Issue(destination), ids)));
Assert.Equal(longest, receipt.MoveId);
Assert.Equal(ids, receipt.MovedIdentityIds);
Assert.Equal(8, JsonDocument.Parse(moves.Requests[0].Body).RootElement.GetProperty("fromProofs").GetArrayLength());

View file

@ -135,7 +135,7 @@ public sealed class FakeMoves(Func<DateTimeOffset> now)
private sealed record Move(string MoveId, string[] FromProofs, string? ToProof, string[]? IdentityIds, string? ReversesMoveId);
private static readonly System.Text.RegularExpressions.Regex MoveIdShape = new("^[A-Za-z0-9_-]{16,64}$");
private static readonly System.Text.RegularExpressions.Regex MoveIdShape = new(@"^[A-Za-z0-9_-]{16,64}\z");
/// <summary>
/// Heimdall's route schema (app.ts, the identity-moves route): moveId pattern, 1 to 8 fromProofs of 1 to 8192 chars, toProof, 1 to 32