diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs
index 80ba8cec5..d3b6e16b8 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs
@@ -321,11 +321,11 @@ public async Task GetValidatedContentAsync_WithNonMatchingContentId_ReturnsFailu
}
///
- /// Verifies that completes preparation.
+ /// Verifies that delivers CSV catalog files before validation.
///
/// A task representing the asynchronous test operation.
[Fact]
- public async Task PrepareContentAsync_WithValidManifest_ReturnsSuccessAsync()
+ public async Task PrepareContentAsync_WithValidManifest_DeliversContentAsync()
{
var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en");
var manifest = new ContentManifest
@@ -335,19 +335,132 @@ public async Task PrepareContentAsync_WithValidManifest_ReturnsSuccessAsync()
Version = "1.08",
ContentType = ContentType.GameInstallation,
TargetGame = GameType.Generals,
+ Files = [new ManifestFile { RelativePath = "game.dat", Size = 12345 }],
+ };
+ var deliveredManifest = new ContentManifest
+ {
+ Id = manifest.Id,
+ Name = manifest.Name,
+ Version = "1.08-delivered",
+ ContentType = manifest.ContentType,
+ TargetGame = manifest.TargetGame,
+ Files = manifest.Files,
};
+ var workingDirectory = "C:\\test\\dir";
+ var progress = Mock.Of>();
+ using var cancellationSource = new CancellationTokenSource();
+
+ var mockDeliverer = new Mock();
+ mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer);
+ mockDeliverer.Setup(d => d.CanDeliver(manifest)).Returns(true);
+ mockDeliverer
+ .Setup(d => d.DeliverContentAsync(manifest, workingDirectory, progress, cancellationSource.Token))
+ .ReturnsAsync(OperationResult.CreateSuccess(deliveredManifest));
var mockValidator = new Mock();
mockValidator.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new ValidationResult(manifestId, []));
- mockValidator.Setup(v => v.ValidateAllAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()))
+ mockValidator.Setup(v => v.ValidateAllAsync(workingDirectory, deliveredManifest, It.IsAny>(), cancellationSource.Token))
.ReturnsAsync(new ValidationResult(manifestId, []));
- var provider = CreateProvider(validator: mockValidator.Object);
+ var provider = CreateProvider(validator: mockValidator.Object, deliverer: mockDeliverer.Object);
- var result = await provider.PrepareContentAsync(manifest, "C:\\test\\dir");
+ var result = await provider.PrepareContentAsync(manifest, workingDirectory, progress, cancellationSource.Token);
result.Success.Should().BeTrue();
+ result.Data.Should().BeSameAs(deliveredManifest);
+ mockDeliverer.Verify(d => d.CanDeliver(manifest), Times.Once);
+ mockDeliverer.Verify(
+ d => d.DeliverContentAsync(manifest, workingDirectory, progress, cancellationSource.Token),
+ Times.Once);
+ mockValidator.Verify(
+ v => v.ValidateAllAsync(workingDirectory, deliveredManifest, It.IsAny>(), cancellationSource.Token),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies that preparation fails without attempting delivery when the HTTP deliverer cannot handle the manifest.
+ ///
+ /// A task representing the asynchronous test operation.
+ [Fact]
+ public async Task PrepareContentAsync_WhenDelivererCannotDeliver_ReturnsFailureAsync()
+ {
+ var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en");
+ var manifest = new ContentManifest
+ {
+ Id = new ManifestId(manifestId),
+ Name = "Generals 1.08 (EN)",
+ Version = "1.08",
+ ContentType = ContentType.GameInstallation,
+ TargetGame = GameType.Generals,
+ Files = [new ManifestFile { RelativePath = "game.dat", Size = 12345 }],
+ };
+
+ var mockDeliverer = new Mock();
+ mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer);
+ mockDeliverer.Setup(d => d.CanDeliver(manifest)).Returns(false);
+
+ var mockValidator = new Mock();
+ mockValidator.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny()))
+ .ReturnsAsync(new ValidationResult(manifestId, []));
+
+ var provider = CreateProvider(validator: mockValidator.Object, deliverer: mockDeliverer.Object);
+
+ var result = await provider.PrepareContentAsync(manifest, "C:\\test\\dir");
+
+ result.Success.Should().BeFalse();
+ result.FirstError.Should().Contain("Cannot deliver content");
+ mockDeliverer.Verify(
+ d => d.DeliverContentAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny()),
+ Times.Never);
+ }
+
+ ///
+ /// Verifies that a delivery failure is returned without continuing to final validation.
+ ///
+ /// A task representing the asynchronous test operation.
+ [Fact]
+ public async Task PrepareContentAsync_WhenDeliveryFails_ReturnsFailureAsync()
+ {
+ var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en");
+ var manifest = new ContentManifest
+ {
+ Id = new ManifestId(manifestId),
+ Name = "Generals 1.08 (EN)",
+ Version = "1.08",
+ ContentType = ContentType.GameInstallation,
+ TargetGame = GameType.Generals,
+ Files = [new ManifestFile { RelativePath = "game.dat", Size = 12345 }],
+ };
+
+ var mockDeliverer = new Mock();
+ mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer);
+ mockDeliverer.Setup(d => d.CanDeliver(manifest)).Returns(true);
+ mockDeliverer
+ .Setup(d => d.DeliverContentAsync(manifest, It.IsAny(), null, It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateFailure("Download failed"));
+
+ var mockValidator = new Mock();
+ mockValidator.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny()))
+ .ReturnsAsync(new ValidationResult(manifestId, []));
+
+ var provider = CreateProvider(validator: mockValidator.Object, deliverer: mockDeliverer.Object);
+
+ var result = await provider.PrepareContentAsync(manifest, "C:\\test\\dir");
+
+ result.Success.Should().BeFalse();
+ result.FirstError.Should().Be("Content delivery failed: Download failed");
+ mockValidator.Verify(
+ v => v.ValidateAllAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny()),
+ Times.Never);
}
private static IInstallationInstructionsService CreateMockInstallationService()
@@ -366,7 +479,9 @@ private static IInstallationInstructionsService CreateMockInstallationService()
return mockInstallService.Object;
}
- private static CsvContentProvider CreateProvider(IContentValidator? validator = null)
+ private static CsvContentProvider CreateProvider(
+ IContentValidator? validator = null,
+ IContentDeliverer? deliverer = null)
{
var mockDiscoverer = new Mock();
mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName);
@@ -380,7 +495,7 @@ private static CsvContentProvider CreateProvider(IContentValidator? validator =
return new CsvContentProvider(
[mockDiscoverer.Object],
[mockResolver.Object],
- [mockDeliverer.Object],
+ [deliverer ?? mockDeliverer.Object],
Mock.Of>(),
validator ?? Mock.Of(),
CreateMockInstallationService());
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/ContentDeliverers/HttpContentDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/ContentDeliverers/HttpContentDelivererTests.cs
new file mode 100644
index 000000000..55e262b92
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/ContentDeliverers/HttpContentDelivererTests.cs
@@ -0,0 +1,235 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Models.Common;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Models.Results;
+using GenHub.Features.Content.Services.ContentDeliverers;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content.Services.ContentDeliverers;
+
+///
+/// Unit tests for .
+///
+public class HttpContentDelivererTests
+{
+ ///
+ /// Verifies that delivery preserves the authoritative manifest and file metadata.
+ ///
+ /// A task representing the asynchronous test operation.
+ [Fact]
+ public async Task DeliverContentAsync_WithRemoteFile_PreservesManifestAndFileMetadataAsync()
+ {
+ var targetDirectory = CreateTargetDirectory();
+ const string relativePath = "data/game.dat";
+ const string expectedHash = "0123456789abcdef";
+ var manifest = CreateManifest("generals-1.08-en", "1.08", relativePath, expectedHash);
+ var downloadService = CreateSuccessfulDownloadService();
+ var deliverer = CreateDeliverer(downloadService.Object);
+ var expectedDestinationPath = Path.GetFullPath(relativePath, Path.GetFullPath(targetDirectory));
+
+ try
+ {
+ var result = await deliverer.DeliverContentAsync(manifest, targetDirectory);
+
+ result.Success.Should().BeTrue();
+ result.Data.Should().BeSameAs(manifest);
+ result.Data!.Id.Should().Be(manifest.Id);
+ result.Data.Version.Should().Be("1.08");
+ result.Data.Files.Should().ContainSingle();
+ result.Data.Files[0].SourceType.Should().Be(ContentSourceType.RemoteDownload);
+ result.Data.Files[0].Hash.Should().Be(expectedHash);
+ result.Data.Files[0].Size.Should().Be(7);
+ result.Data.Files[0].IsRequired.Should().BeFalse();
+ result.Data.Files[0].InstallTarget.Should().Be(ContentInstallTarget.Workspace);
+ File.Exists(expectedDestinationPath).Should().BeTrue();
+
+ downloadService.Verify(
+ d => d.DownloadFileAsync(
+ new Uri("https://example.com/game.dat"),
+ expectedDestinationPath,
+ expectedHash,
+ It.IsAny?>(),
+ It.IsAny()),
+ Times.Once);
+ }
+ finally
+ {
+ Directory.Delete(targetDirectory, recursive: true);
+ }
+ }
+
+ ///
+ /// Verifies that repeated delivery calls return only their own manifest state.
+ ///
+ /// A task representing the asynchronous test operation.
+ [Fact]
+ public async Task DeliverContentAsync_CalledRepeatedly_DoesNotShareManifestStateAsync()
+ {
+ var targetDirectory = CreateTargetDirectory();
+ var firstManifest = CreateManifest("generals-1.08-en", "1.08", "first.dat", "first-hash");
+ var secondManifest = CreateManifest("zerohour-1.04-en", "1.04", "second.dat", "second-hash");
+ var deliverer = CreateDeliverer(CreateSuccessfulDownloadService().Object);
+
+ try
+ {
+ var firstResult = await deliverer.DeliverContentAsync(firstManifest, targetDirectory);
+ var secondResult = await deliverer.DeliverContentAsync(secondManifest, targetDirectory);
+
+ firstResult.Data.Should().BeSameAs(firstManifest);
+ secondResult.Data.Should().BeSameAs(secondManifest);
+ secondResult.Data!.Files.Should().ContainSingle(f => f.RelativePath == "second.dat");
+ secondResult.Data.Files.Should().NotContain(f => f.RelativePath == "first.dat");
+ }
+ finally
+ {
+ Directory.Delete(targetDirectory, recursive: true);
+ }
+ }
+
+ ///
+ /// Verifies that user cancellation remains an .
+ ///
+ /// A task representing the asynchronous test operation.
+ [Fact]
+ public async Task DeliverContentAsync_WhenCancelled_PropagatesCancellationAsync()
+ {
+ var targetDirectory = CreateTargetDirectory();
+ var manifest = CreateManifest("generals-1.08-en", "1.08", "game.dat", "hash");
+ var deliverer = CreateDeliverer(Mock.Of());
+ using var cancellationSource = new CancellationTokenSource();
+ cancellationSource.Cancel();
+
+ try
+ {
+ await Assert.ThrowsAnyAsync(() =>
+ deliverer.DeliverContentAsync(
+ manifest,
+ targetDirectory,
+ cancellationToken: cancellationSource.Token));
+ }
+ finally
+ {
+ Directory.Delete(targetDirectory, recursive: true);
+ }
+ }
+
+ ///
+ /// Verifies that a manifest file cannot escape the delivery target directory.
+ ///
+ /// A task representing the asynchronous test operation.
+ [Fact]
+ public async Task DeliverContentAsync_WithEscapingPath_ReturnsFailureAsync()
+ {
+ var rootDirectory = CreateTargetDirectory();
+ var targetDirectory = Path.Combine(rootDirectory, "target");
+ Directory.CreateDirectory(targetDirectory);
+ var manifest = CreateManifest("generals-1.08-en", "1.08", "../escaped.dat", "hash");
+ var downloadService = new Mock();
+ var deliverer = CreateDeliverer(downloadService.Object);
+
+ try
+ {
+ var result = await deliverer.DeliverContentAsync(manifest, targetDirectory);
+
+ result.Success.Should().BeFalse();
+ result.FirstError.Should().Contain("resolves outside target directory");
+ File.Exists(Path.Combine(rootDirectory, "escaped.dat")).Should().BeFalse();
+ downloadService.Verify(
+ d => d.DownloadFileAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny()),
+ Times.Never);
+ }
+ finally
+ {
+ Directory.Delete(rootDirectory, recursive: true);
+ }
+ }
+
+ private static HttpContentDeliverer CreateDeliverer(IDownloadService downloadService) =>
+ new(downloadService, Mock.Of>());
+
+ private static Mock CreateSuccessfulDownloadService()
+ {
+ var downloadService = new Mock();
+ downloadService
+ .Setup(d => d.DownloadFileAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny()))
+ .Returns((Uri _, string destinationPath, string? _, IProgress? _, CancellationToken _) =>
+ {
+ File.WriteAllText(destinationPath, "content");
+ return Task.FromResult(DownloadResult.CreateSuccess(
+ destinationPath,
+ new FileInfo(destinationPath).Length,
+ TimeSpan.FromMilliseconds(1),
+ hashVerified: true));
+ });
+
+ return downloadService;
+ }
+
+ private static ContentManifest CreateManifest(
+ string contentName,
+ string version,
+ string relativePath,
+ string hash)
+ {
+ var manifestId = ManifestIdGenerator.GeneratePublisherContentId(
+ PublisherTypeConstants.CsvRegistry,
+ ContentType.GameInstallation,
+ contentName);
+
+ return new ContentManifest
+ {
+ Id = new ManifestId(manifestId),
+ Name = contentName,
+ Version = version,
+ ContentType = ContentType.GameInstallation,
+ TargetGame = GameType.Generals,
+ OriginalProviderName = CsvConstants.SourceName,
+ OriginalContentId = contentName,
+ Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = relativePath,
+ SourceType = ContentSourceType.RemoteDownload,
+ InstallTarget = ContentInstallTarget.Workspace,
+ Size = 7,
+ Hash = hash,
+ DownloadUrl = "https://example.com/game.dat",
+ IsRequired = false,
+ IsExecutable = true,
+ Permissions = new FilePermissions { UnixPermissions = "755" },
+ },
+ ],
+ };
+ }
+
+ private static string CreateTargetDirectory()
+ {
+ var targetDirectory = Path.Combine(
+ Path.GetTempPath(),
+ nameof(HttpContentDelivererTests),
+ Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(targetDirectory);
+ return targetDirectory;
+ }
+}
diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs
index af4ec4273..328249450 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/HttpContentDeliverer.cs
@@ -6,7 +6,6 @@
using GenHub.Core.Constants;
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Interfaces.Content;
-using GenHub.Core.Interfaces.Manifest;
using GenHub.Core.Models.Content;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Manifest;
@@ -19,10 +18,9 @@ namespace GenHub.Features.Content.Services.ContentDeliverers;
/// Delivers remote HTTP content.
/// Pure delivery - downloads and extracts content.
///
-public class HttpContentDeliverer(IDownloadService downloadService, IContentManifestBuilder manifestBuilder, ILogger logger) : IContentDeliverer
+public class HttpContentDeliverer(IDownloadService downloadService, ILogger logger) : IContentDeliverer
{
private readonly IDownloadService _downloadService = downloadService;
- private readonly IContentManifestBuilder _manifestBuilder = manifestBuilder;
private readonly ILogger _logger = logger;
///
@@ -56,41 +54,6 @@ public async Task> DeliverContentAsync(
{
try
{
- // Extract publisher from the manifest ID (3rd segment)
- var idSegments = packageManifest.Id.Value.Split('.');
- var publisherId = idSegments.Length >= 3 ? idSegments[2] : "unknown";
-
- var manifestVersionInt = int.TryParse(packageManifest.Version, out var parsedVersion) ? parsedVersion : 0;
- var deliveredManifest = _manifestBuilder
- .WithBasicInfo(publisherId, packageManifest.Name, manifestVersionInt)
- .WithContentType(packageManifest.ContentType, packageManifest.TargetGame)
- .WithPublisher(
- packageManifest.Publisher?.Name ?? string.Empty,
- packageManifest.Publisher?.Website ?? string.Empty,
- packageManifest.Publisher?.SupportUrl ?? string.Empty,
- packageManifest.Publisher?.ContactEmail ?? string.Empty)
- .WithMetadata(
- packageManifest.Metadata?.Description ?? string.Empty,
- packageManifest.Metadata?.Tags,
- packageManifest.Metadata?.IconUrl ?? string.Empty,
- packageManifest.Metadata?.ScreenshotUrls,
- packageManifest.Metadata?.ChangelogUrl ?? string.Empty);
-
- // Add dependencies
- foreach (var dep in packageManifest.Dependencies)
- {
- deliveredManifest.AddDependency(
- dep.Id,
- dep.Name,
- dep.DependencyType,
- dep.InstallBehavior,
- dep.MinVersion ?? string.Empty,
- dep.MaxVersion ?? string.Empty,
- dep.CompatibleVersions,
- dep.IsExclusive,
- dep.ConflictsWith);
- }
-
var filesToDownload = packageManifest.Files.Where(f => !string.IsNullOrEmpty(f.DownloadUrl)).ToList();
var totalFiles = filesToDownload.Count;
var processedFiles = 0;
@@ -100,7 +63,7 @@ public async Task> DeliverContentAsync(
{
cancellationToken.ThrowIfCancellationRequested();
- var localPath = Path.Combine(targetDirectory, file.RelativePath);
+ var localPath = ResolveTargetPath(targetDirectory, file.RelativePath);
// Ensure directory exists
var directory = Path.GetDirectoryName(localPath);
@@ -130,38 +93,17 @@ public async Task> DeliverContentAsync(
$"Failed to download {file.RelativePath}: {downloadResult.FirstError}");
}
- // Add the delivered file using the builder
- await deliveredManifest.AddRemoteFileAsync(
- file.RelativePath,
- file.DownloadUrl ?? string.Empty,
- ContentSourceType.ContentAddressable,
- isExecutable: file.IsExecutable,
- permissions: file.Permissions);
-
+ cancellationToken.ThrowIfCancellationRequested();
processedFiles++;
}
- // Add any other files (without DownloadUrl) as-is
- foreach (var file in packageManifest.Files.Where(f => string.IsNullOrEmpty(f.DownloadUrl)))
- {
- await deliveredManifest.AddLocalFileAsync(
- file.RelativePath,
- file.SourcePath ?? string.Empty,
- ContentSourceType.ContentAddressable,
- isExecutable: file.IsExecutable,
- permissions: file.Permissions);
- }
-
- // Add required directories
- deliveredManifest.AddRequiredDirectories([.. packageManifest.RequiredDirectories]);
-
- // Add installation instructions if present
- if (packageManifest.InstallationInstructions != null)
- {
- deliveredManifest.WithInstallationInstructions(packageManifest.InstallationInstructions.WorkspaceStrategy);
- }
-
- return OperationResult.CreateSuccess(deliveredManifest.Build());
+ // Delivery changes filesystem state only. The resolved manifest remains authoritative
+ // for identity, version, hashes, source types, and installation metadata.
+ return OperationResult.CreateSuccess(packageManifest);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
}
catch (Exception ex)
{
@@ -194,4 +136,22 @@ public Task> ValidateContentAsync(
return Task.FromResult(OperationResult.CreateFailure($"Validation failed: {ex.Message}"));
}
}
+
+ private static string ResolveTargetPath(string targetDirectory, string relativePath)
+ {
+ var targetRoot = Path.GetFullPath(targetDirectory);
+ var targetPath = Path.GetFullPath(relativePath, targetRoot);
+ var relativeTargetPath = Path.GetRelativePath(targetRoot, targetPath);
+
+ if (relativeTargetPath.Equals("..", StringComparison.Ordinal) ||
+ relativeTargetPath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) ||
+ relativeTargetPath.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal) ||
+ Path.IsPathRooted(relativeTargetPath))
+ {
+ throw new InvalidOperationException(
+ $"Content path '{relativePath}' resolves outside target directory.");
+ }
+
+ return targetPath;
+ }
}
diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/CsvContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/CsvContentProvider.cs
index 4cf5f4b59..d13c52c81 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentProviders/CsvContentProvider.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/CsvContentProvider.cs
@@ -87,13 +87,32 @@ public override async Task> GetValidatedContent
}
///
- protected override Task> PrepareContentInternalAsync(
+ protected override async Task> PrepareContentInternalAsync(
ContentManifest manifest,
string workingDirectory,
IProgress? progress,
CancellationToken cancellationToken)
{
Logger.LogDebug("Preparing CSV catalog content for manifest {ManifestId}", manifest.Id);
- return Task.FromResult(OperationResult.CreateSuccess(manifest));
+
+ if (!Deliverer.CanDeliver(manifest))
+ {
+ return OperationResult.CreateFailure(
+ $"Cannot deliver content for manifest {manifest.Id}");
+ }
+
+ var deliveryResult = await Deliverer.DeliverContentAsync(
+ manifest,
+ workingDirectory,
+ progress,
+ cancellationToken);
+
+ if (!deliveryResult.Success)
+ {
+ return OperationResult.CreateFailure(
+ $"Content delivery failed: {deliveryResult.FirstError}");
+ }
+
+ return OperationResult.CreateSuccess(deliveryResult.Data ?? manifest);
}
}