feat(bake): publish validated pak artifacts atomically

This commit is contained in:
Erik 2026-07-24 13:40:58 +02:00
parent 90b378cc70
commit 999201cca7
9 changed files with 362 additions and 14 deletions

View file

@ -0,0 +1,170 @@
using AcDream.Content;
using AcDream.Content.Pak;
namespace AcDream.Bake.Tests;
public sealed class BakeOutputTransactionTests : IDisposable
{
private readonly string _directory =
Path.Combine(Path.GetTempPath(), $"acdream-bake-transaction-{Guid.NewGuid():N}");
public BakeOutputTransactionTests() => Directory.CreateDirectory(_directory);
public void Dispose()
{
try
{
if (Directory.Exists(_directory))
Directory.Delete(_directory, recursive: true);
}
catch
{
// Best-effort test cleanup.
}
}
[Fact]
public void Publish_ReplacesExistingDestinationOnlyAfterValidation()
{
string destination = Path.Combine(_directory, "content.pak");
File.WriteAllText(destination, "old");
int result = BakeOutputTransaction.WriteValidateAndPublish(
destination,
temporary =>
{
File.WriteAllText(temporary, "new");
return 42;
},
(temporary, value) =>
{
Assert.Equal(42, value);
Assert.Equal("new", File.ReadAllText(temporary));
Assert.Equal("old", File.ReadAllText(destination));
});
Assert.Equal(42, result);
Assert.Equal("new", File.ReadAllText(destination));
AssertNoTemporaryFiles();
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Failure_PreservesExistingDestinationAndCleansTemporary(bool failDuringWrite)
{
string destination = Path.Combine(_directory, "content.pak");
File.WriteAllText(destination, "old");
Assert.Throws<InvalidOperationException>(
() => BakeOutputTransaction.WriteValidateAndPublish(
destination,
temporary =>
{
File.WriteAllText(temporary, "partial");
if (failDuringWrite)
throw new InvalidOperationException("write failed");
return 1;
},
(_, _) => throw new InvalidOperationException("validation failed")));
Assert.Equal("old", File.ReadAllText(destination));
AssertNoTemporaryFiles();
}
[Fact]
public void CancellationBeforeWrite_PreservesExistingDestination()
{
string destination = Path.Combine(_directory, "content.pak");
File.WriteAllText(destination, "old");
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.Throws<OperationCanceledException>(
() => BakeOutputTransaction.WriteValidateAndPublish(
destination,
temporary =>
{
File.WriteAllText(temporary, "new");
return 1;
},
(_, _) => { },
cancellation.Token));
Assert.Equal("old", File.ReadAllText(destination));
AssertNoTemporaryFiles();
}
[Fact]
public void ArtifactValidator_RejectsStaleBakeToolVersion()
{
string pak = Path.Combine(_directory, "stale.pak");
var expected = Header();
using (var writer = new PakWriter(pak, expected))
{
writer.AddBlob(
PakKey.Compose(PakAssetType.GfxObjMesh, 1),
new ObjectMeshData { ObjectId = 1 });
writer.Finish();
}
using (var stream = new FileStream(pak, FileMode.Open, FileAccess.ReadWrite))
{
stream.Position = 36;
Span<byte> stale = stackalloc byte[4];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(stale, 1);
stream.Write(stale);
}
var exception = Assert.Throws<InvalidDataException>(
() => BakeArtifactValidator.Validate(pak, expected, expectedTocCount: 1));
Assert.Contains("bake tool version", exception.Message);
}
[Fact]
public void ArtifactValidator_RejectsWrongDatIterationAndCorruptBlob()
{
string pak = Path.Combine(_directory, "content.pak");
var expected = Header();
using (var writer = new PakWriter(pak, expected))
{
writer.AddBlob(
PakKey.Compose(PakAssetType.GfxObjMesh, 1),
new ObjectMeshData { ObjectId = 1 });
writer.Finish();
}
var wrongIteration = expected;
wrongIteration.CellIteration++;
Assert.Throws<InvalidDataException>(
() => BakeArtifactValidator.Validate(pak, wrongIteration, expectedTocCount: 1));
long offset;
using (var reader = new PakReader(pak))
offset = reader.GetBlobOffsetForTest(
PakKey.Compose(PakAssetType.GfxObjMesh, 1));
using (var stream = new FileStream(pak, FileMode.Open, FileAccess.ReadWrite))
{
stream.Position = offset;
int value = stream.ReadByte();
stream.Position = offset;
stream.WriteByte((byte)(value ^ 0xFF));
}
Assert.Throws<InvalidDataException>(
() => BakeArtifactValidator.Validate(pak, expected, expectedTocCount: 1));
}
private PakHeader Header() =>
new()
{
PortalIteration = 10,
CellIteration = 20,
HighResIteration = 30,
LanguageIteration = 40,
BakeToolVersion = PakFormat.CurrentBakeToolVersion,
};
private void AssertNoTemporaryFiles() =>
Assert.Empty(Directory.EnumerateFiles(_directory, ".*.tmp"));
}