feat(bake): publish validated pak artifacts atomically
This commit is contained in:
parent
90b378cc70
commit
999201cca7
9 changed files with 362 additions and 14 deletions
170
tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs
Normal file
170
tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs
Normal 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"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue