feat: harden package lifecycle content boundaries

This commit is contained in:
codex
2026-08-06 09:59:17 +03:00
parent 0e38d8fad2
commit 64fa1f5664
4 changed files with 66 additions and 15 deletions
@@ -1,4 +1,5 @@
using System.Security.Cryptography;
using System.Text.Json;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
using Xunit;
@@ -12,6 +13,16 @@ public sealed class PackageLifecycleTests
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var source=Path.Combine(root,"catalog"); Directory.CreateDirectory(source); await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),[1]); await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),[2]); var store=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(PackageLifecycleFaultPoint.AfterStagingAllocated)); try { await Assert.ThrowsAsync<IOException>(()=>store.MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None)); Assert.Null(await store.GetCurrentVersionAsync("Lifecycle.Package",CancellationToken.None)); } finally { if(Directory.Exists(root)) Directory.Delete(root,true); } }
[Fact] public void PilotRootRejectsUncAndRelativePaths()
{ var resolver=new PathResolver(new Dictionary<string,string?>()); Assert.Throws<InvalidOperationException>(()=>resolver.ResolvePackagePilotRoot(@"\\server\share")); Assert.Throws<InvalidOperationException>(()=>resolver.ResolvePackagePilotRoot("relative")); }
[Fact] public void LifecycleRootsAreTypedDistinctAndConfined()
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var roots=new PathResolver(new Dictionary<string,string?>()).ResolvePackageLifecycleRoots(root); var values=new[]{roots.Catalog.Value,roots.Staging.Value,roots.VersionStore.Value,roots.Active.Value,roots.Evidence.Value}; Assert.Equal(values.Length,values.Distinct(StringComparer.OrdinalIgnoreCase).Count()); Assert.All(values,x=>Assert.StartsWith(roots.Pilot.Value,x,StringComparison.OrdinalIgnoreCase)); }
[Theory]
[InlineData(PackageLifecycleFaultPoint.AfterStagingAllocated)]
[InlineData(PackageLifecycleFaultPoint.DuringPayloadCopy)]
[InlineData(PackageLifecycleFaultPoint.AfterPayloadCopyBeforeValidation)]
[InlineData(PackageLifecycleFaultPoint.BeforeImmutableCommit)]
[InlineData(PackageLifecycleFaultPoint.AfterImmutableCommitBeforeReturn)]
public async Task DeterministicMaterializationFaultsNeverMutatePointers(PackageLifecycleFaultPoint point)
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),[1]);await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),[2]);var store=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(point));try{await Assert.ThrowsAnyAsync<Exception>(()=>store.MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None));Assert.Null(await store.GetCurrentVersionAsync("Lifecycle.Package",CancellationToken.None));Assert.Null(await store.GetPreviousVersionAsync("Lifecycle.Package",CancellationToken.None));}finally{if(Directory.Exists(root))Directory.Delete(root,true);} }
private static PackageRequirement Req(string version) => new("Lifecycle.Package", "Plant3D", 2026, "x64", "pilot", version, "lifecycle-test", true);
private static PackageManifest Manifest(string version, byte[] a, byte[] b) => new(1, "Lifecycle.Package", version, "Plant3D", 2026, "x64", "entry.dll", [
new("entry.dll", a.Length, Convert.ToHexString(SHA256.HashData(a))),
@@ -73,6 +84,21 @@ public sealed class PackageLifecycleTests
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task StrictPointerBindsIndividualPayloadsAndExactLkgRejectsTampering()
{
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);var bytes=new byte[]{4,5};await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),bytes);await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),bytes);var store=new LocalPackageStore(root,new Sha256FileHasher());
try { var p=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None);await store.BindAsync(Req("1.0.0"),"1.0.0",CancellationToken.None);await store.ActivateImmutableAsync(p,Req("1.0.0"),CancellationToken.None);var pointerPath=Path.Combine(root,"Applications","Lifecycle.Package","current.json");using(var doc=JsonDocument.Parse(await File.ReadAllTextAsync(pointerPath))){Assert.Equal(2,doc.RootElement.GetProperty("SchemaVersion").GetInt32());Assert.Equal(2,doc.RootElement.GetProperty("PayloadFiles").GetArrayLength());}Assert.NotNull(await store.ResolveAsync(Req("1.0.0"),CancellationToken.None));var json=await File.ReadAllTextAsync(pointerPath);json=json.Replace("PayloadSetSha256\":\"","PayloadSetSha256\":\"00",StringComparison.Ordinal);await File.WriteAllTextAsync(pointerPath,json);Assert.Null(await store.ResolveAsync(Req("1.0.0"),CancellationToken.None)); }
finally{if(Directory.Exists(root))Directory.Delete(root,true);}
}
[Theory]
[InlineData(PackageLifecycleFaultPoint.BeforePointerTempWrite)]
[InlineData(PackageLifecycleFaultPoint.BeforePointerReplace)]
[InlineData(PackageLifecycleFaultPoint.BeforePointerReadback)]
public async Task PointerFaultsDoNotReturnFalseActivationAndReleaseLock(PackageLifecycleFaultPoint point)
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),[1]);await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),[2]);try{var package=await new LocalPackageStore(root,new Sha256FileHasher()).MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None);var failing=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(point));await Assert.ThrowsAnyAsync<Exception>(()=>failing.ActivateImmutableAsync(package,Req("1.0.0"),CancellationToken.None));var healthy=new LocalPackageStore(root,new Sha256FileHasher());var result=await healthy.ActivateImmutableAsync(package,Req("1.0.0"),CancellationToken.None);Assert.Contains(result.Outcome,new[]{"Activated","AlreadyActiveExact"});}finally{if(Directory.Exists(root))Directory.Delete(root,true);} }
[Fact]
public async Task OldReceiptCannotRollbackNewerActivation()
{