using System.IO.Compression; using System.Security.Cryptography; using System.Text.Json; using LanMountainDesktop.Launcher.Models; using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update; namespace LanMountainDesktop.Launcher.Services; internal sealed class UpdateEngineService { private const string UpdateDirectoryName = "update"; private const string IncomingDirectoryName = "incoming"; private const string SnapshotsDirectoryName = "snapshots"; private const string SignedFileMapName = "files.json"; private const string SignatureFileName = "files.json.sig"; private const string ArchiveFileName = "update.zip"; private const string PlondsFileMapName = "plonds-filemap.json"; private const string PlondsSignatureFileName = "plonds-filemap.sig"; private const string PlondsUpdateMetadataName = "plonds-update.json"; private const string PlondsObjectsDirectoryName = "objects"; private const string PublicKeyFileName = "public-key.pem"; private readonly DeploymentLocator _deploymentLocator; private readonly IUpdateProgressReporter _progressReporter; private readonly string _appRoot; private readonly string _launcherRoot; private readonly string _incomingRoot; private readonly string _snapshotsRoot; private readonly string _installCheckpointPath; public UpdateEngineService(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null) { _deploymentLocator = deploymentLocator; _progressReporter = progressReporter ?? new NullUpdateProgressReporter(); _appRoot = deploymentLocator.GetAppRoot(); var resolver = new DataLocationResolver(_appRoot); _launcherRoot = resolver.ResolveLauncherDataPath(); _incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName); _snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName); _installCheckpointPath = ContractsUpdate.UpdatePaths.GetInstallCheckpointPath(_appRoot); } public LauncherResult CheckPendingUpdate() { var pdcFileMapPath = Path.Combine(_incomingRoot, PlondsFileMapName); var pdcSignaturePath = Path.Combine(_incomingRoot, PlondsSignatureFileName); var pdcUpdatePath = Path.Combine(_incomingRoot, PlondsUpdateMetadataName); if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath)) { var pdcFileMapText = File.ReadAllText(pdcFileMapPath); var pdcFileMap = JsonSerializer.Deserialize(pdcFileMapText, AppJsonContext.Default.PlondsFileMap); if (pdcFileMap is null) { return Failed("update.check", "invalid_manifest", "plonds-filemap.json is invalid."); } var pdcVerified = VerifySignature(pdcFileMapPath, pdcSignaturePath, PlondsSignatureFileName); if (!pdcVerified.Success) { return Failed("update.check", "signature_failed", pdcVerified.Message); } var pdcMetadata = LoadPlondsUpdateMetadata(pdcUpdatePath); return new LauncherResult { Success = true, Stage = "update.check", Code = "available", Message = "Pending PLONDS update is available.", CurrentVersion = _deploymentLocator.GetCurrentVersion(), TargetVersion = ResolvePlondsTargetVersion(pdcFileMap, pdcMetadata) }; } var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName); var archivePath = Path.Combine(_incomingRoot, ArchiveFileName); var signaturePath = Path.Combine(_incomingRoot, SignatureFileName); if (!File.Exists(fileMapPath) || !File.Exists(archivePath)) { return new LauncherResult { Success = true, Stage = "update.check", Code = "noop", Message = "No pending update." }; } var fileMapText = File.ReadAllText(fileMapPath); var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap); if (fileMap is null) { return Failed("update.check", "invalid_manifest", "files.json is invalid."); } var verified = VerifySignature(fileMapPath, signaturePath, SignatureFileName); if (!verified.Success) { return Failed("update.check", "signature_failed", verified.Message); } return new LauncherResult { Success = true, Stage = "update.check", Code = "available", Message = "Pending update is available.", CurrentVersion = _deploymentLocator.GetCurrentVersion(), TargetVersion = fileMap.ToVersion }; } public Task DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken) { _ = manifestUrl; _ = signatureUrl; _ = archiveUrl; _ = cancellationToken; return Task.FromResult(new LauncherResult { Success = false, Stage = "update.download", Code = "host_managed_only", Message = "Launcher no longer performs network downloads. Host must download update payload into incoming directory first." }); } public async Task ApplyPendingUpdateAsync() { Directory.CreateDirectory(_incomingRoot); Directory.CreateDirectory(_snapshotsRoot); var stateValidation = ValidateIncomingState(); if (!stateValidation.Success) { return stateValidation; } var applyLockPath = ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(_appRoot); try { File.WriteAllText(applyLockPath, DateTimeOffset.UtcNow.ToString("O")); } catch (Exception ex) { return Failed("update.apply", "lock_conflict", $"Failed to acquire apply lock: {ex.Message}"); } try { var pdcFileMapPath = Path.Combine(_incomingRoot, PlondsFileMapName); var pdcSignaturePath = Path.Combine(_incomingRoot, PlondsSignatureFileName); var pdcUpdatePath = Path.Combine(_incomingRoot, PlondsUpdateMetadataName); if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath)) { return await ApplyPendingPlondsUpdateAsync(pdcFileMapPath, pdcSignaturePath, pdcUpdatePath); } var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName); var signaturePath = Path.Combine(_incomingRoot, SignatureFileName); var archivePath = Path.Combine(_incomingRoot, ArchiveFileName); if (!File.Exists(fileMapPath) || !File.Exists(archivePath)) { return new LauncherResult { Success = true, Stage = "update.apply", Code = "noop", Message = "No update payload found." }; } _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying signature...", 0, null, 0, 0)); var verifyResult = VerifySignature(fileMapPath, signaturePath, SignatureFileName); if (!verifyResult.Success) { _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false)); return Failed("update.apply", "signature_failed", verifyResult.Message); } var fileMapText = await File.ReadAllTextAsync(fileMapPath); var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap); if (fileMap is null || fileMap.Files.Count == 0) { _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No update file entries were found.", false)); return Failed("update.apply", "invalid_manifest", "No update file entries were found."); } var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory(); if (string.IsNullOrWhiteSpace(currentDeployment)) { // Initial install path: no current deployment exists, so apply the staged package directly. } var currentVersion = _deploymentLocator.GetCurrentVersion(); if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) && !string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase)) { return Failed( "update.apply", "version_mismatch", $"Update requires source version {fileMap.FromVersion} but current is {currentVersion}."); } var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? currentVersion : fileMap.ToVersion!; var existingCheckpoint = LoadInstallCheckpoint(); var canResume = existingCheckpoint is not null && string.Equals(existingCheckpoint.SourceVersion, currentVersion, StringComparison.OrdinalIgnoreCase) && string.Equals(existingCheckpoint.TargetVersion, targetVersion, StringComparison.OrdinalIgnoreCase) && string.Equals(existingCheckpoint.SourceDirectory ?? string.Empty, currentDeployment ?? string.Empty, StringComparison.OrdinalIgnoreCase) && Directory.Exists(existingCheckpoint.TargetDirectory) && File.Exists(Path.Combine(existingCheckpoint.TargetDirectory, ".partial")); if (existingCheckpoint is not null && !canResume) { return Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload."); } var targetDeployment = canResume ? existingCheckpoint!.TargetDirectory : _deploymentLocator.BuildNextDeploymentDirectory(targetVersion); var partialMarker = Path.Combine(targetDeployment, ".partial"); var snapshot = new SnapshotMetadata { SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"), SourceVersion = currentVersion, TargetVersion = targetVersion, CreatedAt = DateTimeOffset.UtcNow, SourceDirectory = currentDeployment, TargetDirectory = targetDeployment, Status = "pending" }; var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json"); var checkpoint = canResume ? existingCheckpoint! : new InstallCheckpoint { SnapshotId = snapshot.SnapshotId, SourceVersion = currentVersion, TargetVersion = targetVersion, SourceDirectory = currentDeployment, TargetDirectory = targetDeployment, IsInitialDeployment = false, AppliedCount = 0, VerifiedCount = 0 }; var extractRoot = Path.Combine(_incomingRoot, "extracted"); try { SaveSnapshot(snapshotPath, snapshot); if (Directory.Exists(extractRoot)) { Directory.Delete(extractRoot, true); } Directory.CreateDirectory(extractRoot); ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true); if (!canResume) { _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileMap.Files.Count)); Directory.CreateDirectory(targetDeployment); File.WriteAllText(partialMarker, string.Empty); } SaveInstallCheckpoint(checkpoint); _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30, null, checkpoint.AppliedCount, fileMap.Files.Count)); for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileMap.Files.Count; fileIndex++) { var file = fileMap.Files[fileIndex]; ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot); checkpoint.AppliedCount = fileIndex + 1; SaveInstallCheckpoint(checkpoint); _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30 + (checkpoint.AppliedCount * 30 / fileMap.Files.Count), file.Path, checkpoint.AppliedCount, fileMap.Files.Count)); } _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65, null, checkpoint.VerifiedCount, fileMap.Files.Count)); for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileMap.Files.Count; verifyIndex++) { var file = fileMap.Files[verifyIndex]; if (!NeedsVerification(file)) { checkpoint.VerifiedCount = verifyIndex + 1; SaveInstallCheckpoint(checkpoint); continue; } var fullPath = Path.Combine(targetDeployment, file.Path); var actualHash = ComputeSha256Hex(fullPath); if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException($"File hash mismatch for '{file.Path}'."); } checkpoint.VerifiedCount = verifyIndex + 1; SaveInstallCheckpoint(checkpoint); _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileMap.Files.Count), file.Path, checkpoint.VerifiedCount, fileMap.Files.Count)); } _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileMap.Files.Count, fileMap.Files.Count)); ActivateDeployment(currentDeployment, targetDeployment); snapshot.Status = "applied"; SaveSnapshot(snapshotPath, snapshot); CleanupIncomingArtifacts(); RetainDeploymentsForRollback(); _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileMap.Files.Count, fileMap.Files.Count)); _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, currentVersion, targetVersion, null, false)); return new LauncherResult { Success = true, Stage = "update.apply", Code = "ok", Message = $"Updated to {targetVersion}.", CurrentVersion = currentVersion, TargetVersion = targetVersion }; } catch (Exception ex) { _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0)); var rollbackResult = TryRollbackOnFailure(snapshot); snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed"; SaveSnapshot(snapshotPath, snapshot); var errorMessage = rollbackResult.Success ? ex.Message : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}"; _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, errorMessage, rollbackResult.Success)); return new LauncherResult { Success = false, Stage = "update.apply", Code = rollbackResult.Success ? "apply_failed" : "rollback_failed", Message = rollbackResult.Success ? "Failed to apply update. Rolled back to previous version." : "Failed to apply update and rollback failed.", ErrorMessage = errorMessage, CurrentVersion = currentVersion, RolledBackTo = rollbackResult.Success ? currentVersion : null }; } finally { DeleteInstallCheckpoint(); try { if (Directory.Exists(extractRoot)) { Directory.Delete(extractRoot, true); } } catch { } } } finally { try { if (File.Exists(applyLockPath)) { File.Delete(applyLockPath); } } catch { } } } private LauncherResult ValidateIncomingState() { var applyLockPath = ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(_appRoot); if (File.Exists(applyLockPath)) { return Failed("update.apply", "lock_conflict", "Another update apply operation is already in progress."); } var deploymentLockPath = ContractsUpdate.UpdatePaths.GetDeploymentLockPath(_appRoot); if (!File.Exists(deploymentLockPath)) { return Failed("update.apply", "staging_incomplete", "Deployment lock is missing. Please redownload the update."); } var markerPath = ContractsUpdate.UpdatePaths.GetDownloadMarkerPath(_appRoot); var hasPlondsMap = File.Exists(Path.Combine(_incomingRoot, PlondsFileMapName)); var hasLegacyMap = File.Exists(Path.Combine(_incomingRoot, SignedFileMapName)); if (hasPlondsMap && !File.Exists(markerPath)) { return Failed("update.apply", "staging_incomplete", "Download marker is missing for pending PLONDS update."); } if (!hasPlondsMap && !hasLegacyMap) { return new LauncherResult { Success = true, Stage = "update.apply", Code = "noop", Message = "No update payload found." }; } return new LauncherResult { Success = true, Stage = "update.apply", Code = "ok", Message = "Incoming update state validated." }; } private async Task ApplyPendingPlondsUpdateAsync( string pdcFileMapPath, string pdcSignaturePath, string pdcUpdatePath) { _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying PLONDS signature...", 0, null, 0, 0)); var verifyResult = VerifySignature(pdcFileMapPath, pdcSignaturePath, PlondsSignatureFileName); if (!verifyResult.Success) { _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false)); return Failed("update.apply", "signature_failed", verifyResult.Message); } var fileMapText = await File.ReadAllTextAsync(pdcFileMapPath).ConfigureAwait(false); var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.PlondsFileMap) ?? new PlondsFileMap(); var fileEntries = CollectPlondsFileEntries(fileMap); if (fileEntries.Count == 0) { PopulatePlondsManifestFromRawJson(fileMapText, fileMap, fileEntries); } if (fileEntries.Count == 0) { _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No PLONDS file entries were found.", false)); return Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found."); } var pdcMetadata = LoadPlondsUpdateMetadata(pdcUpdatePath); var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory(); var currentVersion = _deploymentLocator.GetCurrentVersion(); var sourceVersion = string.IsNullOrWhiteSpace(currentVersion) ? "0.0.0" : currentVersion; var expectedSourceVersion = ResolvePlondsSourceVersion(fileMap, pdcMetadata); if (!string.IsNullOrWhiteSpace(expectedSourceVersion) && !string.Equals(expectedSourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase)) { return Failed( "update.apply", "version_mismatch", $"PLONDS update requires source version {expectedSourceVersion} but current is {sourceVersion}."); } var targetVersion = ResolvePlondsTargetVersion(fileMap, pdcMetadata); if (string.IsNullOrWhiteSpace(targetVersion)) { targetVersion = sourceVersion; } var isInitialDeployment = string.IsNullOrWhiteSpace(currentDeployment); var existingCheckpoint = LoadInstallCheckpoint(); var canResume = existingCheckpoint is not null && string.Equals(existingCheckpoint.SourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase) && string.Equals(existingCheckpoint.TargetVersion, targetVersion, StringComparison.OrdinalIgnoreCase) && string.Equals(existingCheckpoint.SourceDirectory ?? string.Empty, currentDeployment ?? string.Empty, StringComparison.OrdinalIgnoreCase) && Directory.Exists(existingCheckpoint.TargetDirectory) && File.Exists(Path.Combine(existingCheckpoint.TargetDirectory, ".partial")); if (existingCheckpoint is not null && !canResume) { return Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload."); } var targetDeployment = canResume ? existingCheckpoint!.TargetDirectory : _deploymentLocator.BuildNextDeploymentDirectory(targetVersion!); var partialMarker = Path.Combine(targetDeployment, ".partial"); var snapshot = new SnapshotMetadata { SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"), SourceVersion = sourceVersion, TargetVersion = targetVersion, CreatedAt = DateTimeOffset.UtcNow, SourceDirectory = currentDeployment ?? string.Empty, TargetDirectory = targetDeployment, Status = "pending" }; var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json"); var checkpoint = canResume ? existingCheckpoint! : new InstallCheckpoint { SnapshotId = snapshot.SnapshotId, SourceVersion = sourceVersion, TargetVersion = targetVersion, SourceDirectory = currentDeployment, TargetDirectory = targetDeployment, IsInitialDeployment = isInitialDeployment, AppliedCount = 0, VerifiedCount = 0 }; try { SaveSnapshot(snapshotPath, snapshot); if (!canResume) { if (Directory.Exists(targetDeployment)) { Directory.Delete(targetDeployment, true); } _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count)); Directory.CreateDirectory(targetDeployment); File.WriteAllText(partialMarker, string.Empty); } SaveInstallCheckpoint(checkpoint); _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, checkpoint.AppliedCount, fileEntries.Count)); for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileEntries.Count; fileIndex++) { var entry = fileEntries[fileIndex]; ApplyPlondsFileEntry(entry, currentDeployment, targetDeployment); checkpoint.AppliedCount = fileIndex + 1; SaveInstallCheckpoint(checkpoint); _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (checkpoint.AppliedCount * 30 / fileEntries.Count), entry.Path, checkpoint.AppliedCount, fileEntries.Count)); } _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, checkpoint.VerifiedCount, fileEntries.Count)); for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileEntries.Count; verifyIndex++) { var entry = fileEntries[verifyIndex]; VerifyPlondsFileEntry(entry, targetDeployment); checkpoint.VerifiedCount = verifyIndex + 1; SaveInstallCheckpoint(checkpoint); _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileEntries.Count), entry.Path, checkpoint.VerifiedCount, fileEntries.Count)); } if (isInitialDeployment) { File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty); if (File.Exists(partialMarker)) { File.Delete(partialMarker); } } else { _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.Count)); ActivateDeployment(currentDeployment!, targetDeployment); } snapshot.Status = "applied"; SaveSnapshot(snapshotPath, snapshot); CleanupIncomingArtifacts(); RetainDeploymentsForRollback(); _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count)); _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, sourceVersion, targetVersion, null, false)); return new LauncherResult { Success = true, Stage = "update.apply", Code = "ok", Message = $"Updated to {targetVersion}.", CurrentVersion = sourceVersion, TargetVersion = targetVersion }; } catch (Exception ex) { if (isInitialDeployment) { try { if (Directory.Exists(targetDeployment)) { Directory.Delete(targetDeployment, true); } } catch { } snapshot.Status = "failed"; SaveSnapshot(snapshotPath, snapshot); _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, "0.0.0", targetVersion, ex.Message, false)); return new LauncherResult { Success = false, Stage = "update.apply", Code = "initial_deploy_failed", Message = "Failed to apply initial PLONDS deployment.", ErrorMessage = ex.Message, CurrentVersion = "0.0.0", TargetVersion = targetVersion }; } _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0)); var rollbackResult = TryRollbackOnFailure(snapshot); snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed"; SaveSnapshot(snapshotPath, snapshot); var errorMessage = rollbackResult.Success ? ex.Message : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}"; _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, errorMessage, rollbackResult.Success)); return new LauncherResult { Success = false, Stage = "update.apply", Code = rollbackResult.Success ? "apply_failed" : "rollback_failed", Message = rollbackResult.Success ? "Failed to apply PLONDS update. Rolled back to previous version." : "Failed to apply PLONDS update and rollback failed.", ErrorMessage = errorMessage, CurrentVersion = sourceVersion, RolledBackTo = rollbackResult.Success ? sourceVersion : null }; } finally { DeleteInstallCheckpoint(); } } private void ApplyPlondsFileEntry(PlondsFileEntry file, string? currentDeployment, string targetDeployment) { var normalizedPath = NormalizeRelativePath(file.Path); var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!; if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) { return; } var targetPath = Path.Combine(targetDeployment, normalizedPath); EnsurePathWithinRoot(targetPath, targetDeployment); var targetDir = Path.GetDirectoryName(targetPath); if (!string.IsNullOrWhiteSpace(targetDir)) { Directory.CreateDirectory(targetDir); } if (string.Equals(action, "reuse", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrWhiteSpace(currentDeployment)) { throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because no source deployment is available."); } var sourcePath = Path.Combine(currentDeployment, normalizedPath); EnsurePathWithinRoot(sourcePath, currentDeployment); if (!File.Exists(sourcePath)) { throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment."); } File.Copy(sourcePath, targetPath, overwrite: true); ApplyUnixFileModeIfPresent(targetPath, file); return; } var objectPath = ResolvePlondsObjectPath(file); var objectBytes = File.ReadAllBytes(objectPath); var restoredBytes = TryInflateGzip(objectBytes) ?? objectBytes; File.WriteAllBytes(targetPath, restoredBytes); ApplyUnixFileModeIfPresent(targetPath, file); } private void VerifyPlondsFileEntry(PlondsFileEntry file, string targetDeployment) { var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!; if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) { return; } var targetPath = Path.Combine(targetDeployment, NormalizeRelativePath(file.Path)); EnsurePathWithinRoot(targetPath, targetDeployment); if (!File.Exists(targetPath)) { throw new FileNotFoundException($"Expected target file was not created: {file.Path}"); } if (TryGetExpectedSha512(file, out var expectedSha512)) { var actualSha512 = ComputeSha512(targetPath); if (!actualSha512.AsSpan().SequenceEqual(expectedSha512)) { throw new InvalidOperationException($"SHA-512 mismatch for '{file.Path}'."); } return; } if (!string.IsNullOrWhiteSpace(file.Sha256)) { var expectedSha256 = NormalizeHashText(file.Sha256); var actualSha256 = ComputeSha256Hex(targetPath); if (!string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException($"SHA-256 mismatch for '{file.Path}'."); } } } private string ResolvePlondsObjectPath(PlondsFileEntry file) { var candidates = new List(); AddPlondsPathCandidates(candidates, file.ObjectPath); AddPlondsPathCandidates(candidates, file.ObjectKey); AddPlondsPathCandidates(candidates, file.ArchivePath); AddPlondsPathCandidates(candidates, file.ObjectUrl); AddPlondsPathCandidates(candidates, file.Url); if (TryGetExpectedObjectSha512(file, out var expectedSha512) || TryGetExpectedSha512(file, out expectedSha512)) { var hashHex = Convert.ToHexString(expectedSha512).ToLowerInvariant(); AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, hashHex)); if (hashHex.Length > 2) { AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, hashHex[..2], hashHex)); // Backward compatibility for previously staged paths. AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, hashHex[..2], hashHex[2..])); } AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, $"{hashHex}.gz")); } foreach (var relativePath in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) { var fullPath = Path.GetFullPath(Path.Combine(_incomingRoot, relativePath)); if (!fullPath.StartsWith(Path.GetFullPath(_incomingRoot), StringComparison.OrdinalIgnoreCase)) { continue; } if (File.Exists(fullPath)) { return fullPath; } } throw new FileNotFoundException($"Unable to resolve object payload for '{file.Path}'."); } private static byte[]? TryInflateGzip(byte[] payload) { try { using var input = new MemoryStream(payload, writable: false); using var gzip = new GZipStream(input, CompressionMode.Decompress); using var output = new MemoryStream(); gzip.CopyTo(output); return output.ToArray(); } catch { return null; } } private void AddPlondsPathCandidates(ICollection candidates, string? value) { if (string.IsNullOrWhiteSpace(value)) { return; } var normalized = value.Trim(); if (Uri.TryCreate(normalized, UriKind.Absolute, out var absoluteUri)) { normalized = Uri.UnescapeDataString(absoluteUri.AbsolutePath); } normalized = normalized.TrimStart('/', '\\'); if (string.IsNullOrWhiteSpace(normalized)) { return; } normalized = normalized.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); candidates.Add(normalized); if (!normalized.StartsWith($"{PlondsObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) { candidates.Add(Path.Combine(PlondsObjectsDirectoryName, normalized)); } var fileName = Path.GetFileName(normalized); if (!string.IsNullOrWhiteSpace(fileName)) { candidates.Add(Path.Combine(PlondsObjectsDirectoryName, fileName)); } } private static bool TryGetExpectedSha512(PlondsFileEntry file, out byte[] expected) { expected = []; if (file.Sha512Bytes is { Length: > 0 }) { expected = file.Sha512Bytes; return true; } if (file.Hash is not null) { if (file.Hash.Bytes is { Length: > 0 }) { expected = file.Hash.Bytes; return true; } if (string.IsNullOrWhiteSpace(file.Hash.Algorithm) || file.Hash.Algorithm.Contains("sha512", StringComparison.OrdinalIgnoreCase)) { if (TryParseHashBytes(file.Hash.Value, out expected)) { return true; } } } if (TryParseHashBytes(file.Sha512, out expected)) { return true; } return TryParseHashBytes(file.Sha512Base64, out expected); } private static bool TryGetExpectedObjectSha512(PlondsFileEntry file, out byte[] expected) { expected = []; if (file.Hash is null) { return false; } if (file.Hash.Bytes is { Length: > 0 }) { expected = file.Hash.Bytes; return true; } if (!string.IsNullOrWhiteSpace(file.Hash.Algorithm) && !file.Hash.Algorithm.Contains("sha512", StringComparison.OrdinalIgnoreCase)) { return false; } return TryParseHashBytes(file.Hash.Value, out expected); } private static bool TryParseHashBytes(string? rawHash, out byte[] bytes) { bytes = []; if (string.IsNullOrWhiteSpace(rawHash)) { return false; } var normalized = rawHash.Trim(); var separator = normalized.IndexOf(':'); if (separator >= 0 && separator < normalized.Length - 1) { normalized = normalized[(separator + 1)..].Trim(); } var compact = normalized.Replace("-", string.Empty); if (compact.Length > 0 && compact.Length % 2 == 0 && IsHexString(compact)) { try { bytes = Convert.FromHexString(compact); return true; } catch { return false; } } try { bytes = Convert.FromBase64String(normalized); return bytes.Length > 0; } catch { return false; } } private static bool IsHexString(string value) { foreach (var ch in value) { if (!Uri.IsHexDigit(ch)) { return false; } } return true; } private static string NormalizeHashText(string hash) { var normalized = hash.Trim(); var separator = normalized.IndexOf(':'); if (separator >= 0 && separator < normalized.Length - 1) { normalized = normalized[(separator + 1)..]; } return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant(); } private static List CollectPlondsFileEntries(PlondsFileMap fileMap) { var files = new List(); if (fileMap.Files is { Count: > 0 }) { files.AddRange(fileMap.Files); } if (fileMap.Components is null) { return files; } foreach (var component in fileMap.Components) { if (component.Files is { Count: > 0 }) { files.AddRange(component.Files); } } return files; } private static void PopulatePlondsManifestFromRawJson(string fileMapJson, PlondsFileMap fileMap, ICollection files) { if (string.IsNullOrWhiteSpace(fileMapJson)) { return; } using var document = JsonDocument.Parse(fileMapJson); var root = document.RootElement; if (root.ValueKind != JsonValueKind.Object) { return; } fileMap.FromVersion ??= ReadJsonStringIgnoreCase(root, "fromversion"); fileMap.ToVersion ??= ReadJsonStringIgnoreCase(root, "toversion"); fileMap.Version ??= ReadJsonStringIgnoreCase(root, "version"); fileMap.Platform ??= ReadJsonStringIgnoreCase(root, "platform"); fileMap.Arch ??= ReadJsonStringIgnoreCase(root, "arch"); fileMap.DistributionId ??= ReadJsonStringIgnoreCase(root, "distributionid"); if (TryGetJsonPropertyIgnoreCase(root, "metadata", out var metadataNode) && metadataNode.ValueKind == JsonValueKind.Object) { foreach (var property in metadataNode.EnumerateObject()) { var key = property.Name; if (string.IsNullOrWhiteSpace(key)) { continue; } var value = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : property.Value.ToString(); if (string.IsNullOrWhiteSpace(value)) { continue; } fileMap.Metadata[key] = value; } } if (TryGetJsonPropertyIgnoreCase(root, "files", out var rootFilesNode)) { ParsePlondsFilesNode(rootFilesNode, null, files); } if (!TryGetJsonPropertyIgnoreCase(root, "components", out var componentsNode)) { return; } if (componentsNode.ValueKind == JsonValueKind.Object) { foreach (var component in componentsNode.EnumerateObject()) { if (component.Value.ValueKind != JsonValueKind.Object) { continue; } if (TryGetJsonPropertyIgnoreCase(component.Value, "files", out var componentFilesNode)) { ParsePlondsFilesNode(componentFilesNode, component.Name, files); } } return; } if (componentsNode.ValueKind != JsonValueKind.Array) { return; } foreach (var component in componentsNode.EnumerateArray()) { if (component.ValueKind != JsonValueKind.Object) { continue; } var componentName = ReadJsonStringIgnoreCase(component, "name"); if (TryGetJsonPropertyIgnoreCase(component, "files", out var componentFilesNode)) { ParsePlondsFilesNode(componentFilesNode, componentName, files); } } } private static void ParsePlondsFilesNode(JsonElement filesNode, string? componentName, ICollection files) { if (filesNode.ValueKind == JsonValueKind.Object) { foreach (var fileEntry in filesNode.EnumerateObject()) { if (fileEntry.Value.ValueKind != JsonValueKind.Object) { continue; } if (TryCreatePlondsFileEntry(fileEntry.Name, componentName, fileEntry.Value, out var parsed)) { files.Add(parsed); } } return; } if (filesNode.ValueKind != JsonValueKind.Array) { return; } foreach (var fileEntry in filesNode.EnumerateArray()) { if (fileEntry.ValueKind != JsonValueKind.Object) { continue; } var fallbackPath = ReadJsonStringIgnoreCase(fileEntry, "path"); if (TryCreatePlondsFileEntry(fallbackPath, componentName, fileEntry, out var parsed)) { files.Add(parsed); } } } private static bool TryCreatePlondsFileEntry(string? fallbackPath, string? componentName, JsonElement node, out PlondsFileEntry entry) { entry = new PlondsFileEntry(); var path = ReadJsonStringIgnoreCase(node, "path"); if (string.IsNullOrWhiteSpace(path)) { path = fallbackPath; } if (string.IsNullOrWhiteSpace(path)) { return false; } var fileSha512 = ReadJsonByteArrayIgnoreCase(node, "filesha512") ?? ReadJsonByteArrayIgnoreCase(node, "sha512"); var archiveSha512 = ReadJsonByteArrayIgnoreCase(node, "archivesha512"); var fileSha512Text = ReadJsonStringIgnoreCase(node, "filesha512") ?? ReadJsonStringIgnoreCase(node, "sha512"); var archiveSha512Text = ReadJsonStringIgnoreCase(node, "archivesha512"); var downloadUrl = ReadJsonStringIgnoreCase(node, "archivedownloadurl") ?? ReadJsonStringIgnoreCase(node, "downloadurl") ?? ReadJsonStringIgnoreCase(node, "url"); var objectPath = ReadJsonStringIgnoreCase(node, "objectpath") ?? ReadJsonStringIgnoreCase(node, "archivepath"); var objectKey = ReadJsonStringIgnoreCase(node, "objectkey"); var action = ReadJsonStringIgnoreCase(node, "action"); var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); if (!string.IsNullOrWhiteSpace(componentName)) { metadata["component"] = componentName; } if (TryGetJsonPropertyIgnoreCase(node, "metadata", out var metadataNode) && metadataNode.ValueKind == JsonValueKind.Object) { foreach (var property in metadataNode.EnumerateObject()) { if (property.Value.ValueKind == JsonValueKind.Null || property.Value.ValueKind == JsonValueKind.Undefined) { continue; } var value = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : property.Value.ToString(); if (string.IsNullOrWhiteSpace(value)) { continue; } metadata[property.Name] = value; } } entry = new PlondsFileEntry { Path = path, Action = string.IsNullOrWhiteSpace(action) ? "replace" : action, Url = downloadUrl, ObjectUrl = ReadJsonStringIgnoreCase(node, "objecturl"), ObjectPath = objectPath, ObjectKey = objectKey, ArchivePath = ReadJsonStringIgnoreCase(node, "archivepath"), Sha256 = ReadJsonStringIgnoreCase(node, "sha256") ?? ReadJsonStringIgnoreCase(node, "filesha256"), Sha512 = fileSha512Text, Sha512Base64 = null, Sha512Bytes = fileSha512, Metadata = metadata }; if (archiveSha512 is { Length: > 0 } || !string.IsNullOrWhiteSpace(archiveSha512Text)) { entry.Hash = new PlondsHashDescriptor { Algorithm = "sha512", Bytes = archiveSha512, Value = archiveSha512Text ?? (archiveSha512 is { Length: > 0 } ? Convert.ToHexString(archiveSha512).ToLowerInvariant() : null) }; } else if (TryGetJsonPropertyIgnoreCase(node, "hash", out var hashNode) && hashNode.ValueKind == JsonValueKind.Object) { entry.Hash = new PlondsHashDescriptor { Algorithm = ReadJsonStringIgnoreCase(hashNode, "algorithm"), Value = ReadJsonStringIgnoreCase(hashNode, "value"), Bytes = ReadJsonByteArrayIgnoreCase(hashNode, "bytes") }; } return true; } private static void ApplyUnixFileModeIfPresent(string targetPath, PlondsFileEntry file) { if (OperatingSystem.IsWindows()) { return; } if (!file.Metadata.TryGetValue("unixFileMode", out var rawMode) || string.IsNullOrWhiteSpace(rawMode)) { return; } try { var normalized = rawMode.Trim(); var modeValue = Convert.ToInt32(normalized, 8); File.SetUnixFileMode(targetPath, (UnixFileMode)modeValue); } catch { // Best-effort only. A bad mode should not break the entire update. } } private static bool TryGetJsonPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value) { if (node.ValueKind == JsonValueKind.Object) { foreach (var property in node.EnumerateObject()) { if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) { value = property.Value; return true; } } } value = default; return false; } private static string? ReadJsonStringIgnoreCase(JsonElement node, string propertyName) { if (!TryGetJsonPropertyIgnoreCase(node, propertyName, out var value)) { return null; } return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null ? null : value.ToString(); } private static byte[]? ReadJsonByteArrayIgnoreCase(JsonElement node, string propertyName) { if (!TryGetJsonPropertyIgnoreCase(node, propertyName, out var value)) { return null; } return ParseJsonByteArrayValue(value); } private static byte[]? ParseJsonByteArrayValue(JsonElement value) { switch (value.ValueKind) { case JsonValueKind.Array: { var bytes = new byte[value.GetArrayLength()]; var index = 0; foreach (var element in value.EnumerateArray()) { if (!element.TryGetInt32(out var number) || number < byte.MinValue || number > byte.MaxValue) { return null; } bytes[index++] = (byte)number; } return bytes; } case JsonValueKind.String: { var text = value.GetString(); return TryParseHashBytes(text, out var parsed) ? parsed : null; } default: return null; } } private static PlondsUpdateMetadata? LoadPlondsUpdateMetadata(string path) { if (!File.Exists(path)) { return null; } try { var text = File.ReadAllText(path); if (string.IsNullOrWhiteSpace(text)) { return null; } return JsonSerializer.Deserialize(text, AppJsonContext.Default.PlondsUpdateMetadata); } catch { return null; } } private static string? ResolvePlondsSourceVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata) { return FirstNonEmpty( metadata?.FromVersion, fileMap.FromVersion, TryGetMetadataValue(fileMap.Metadata, "fromVersion"), TryGetMetadataValue(fileMap.Metadata, "sourceVersion")); } private static string? ResolvePlondsTargetVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata) { return FirstNonEmpty( metadata?.ToVersion, fileMap.ToVersion, fileMap.Version, TryGetMetadataValue(fileMap.Metadata, "toVersion"), TryGetMetadataValue(fileMap.Metadata, "targetVersion")); } private static string? TryGetMetadataValue(Dictionary? metadata, string key) { if (metadata is null || metadata.Count == 0) { return null; } foreach (var pair in metadata) { if (!string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase)) { continue; } if (!string.IsNullOrWhiteSpace(pair.Value)) { return pair.Value; } } return null; } private static string? FirstNonEmpty(params string?[] values) { foreach (var value in values) { if (!string.IsNullOrWhiteSpace(value)) { return value; } } return null; } /// /// 闁稿繈鍔嶉弻濠勨偓鐟邦槼椤ュ﹪宕烽悜妯荤彲闁挎稒姘ㄥú鍧楀箳閵夈儳瀹夐柣顫妽濞插潡寮弶鍨樁濞达絾绮堢拹鐔革純閺嶎煈鍋ч梺顔哄妿鐠? /// private async Task ApplyInitialDeploymentAsync( SignedFileMap fileMap, string archivePath, string fileMapPath, string signaturePath) { var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? "1.0.0" : fileMap.ToVersion!; var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion); var partialMarker = Path.Combine(targetDeployment, ".partial"); var snapshotPath = Path.Combine(_snapshotsRoot, $"initial-{Guid.NewGuid():N}.json"); var extractRoot = Path.Combine(_incomingRoot, "extracted"); try { // Save a snapshot for diagnostics and future rollback consistency. var snapshot = new SnapshotMetadata { SnapshotId = Guid.NewGuid().ToString("N"), SourceVersion = "0.0.0", TargetVersion = targetVersion, CreatedAt = DateTimeOffset.UtcNow, SourceDirectory = "", TargetDirectory = targetDeployment, Status = "pending" }; SaveSnapshot(snapshotPath, snapshot); // 婵炴挸鎳愰幃濠囩嵁閹澏鎺楀储鐎n偅绾柡鍌涙緲鐎? if (Directory.Exists(extractRoot)) { Directory.Delete(extractRoot, true); } Directory.CreateDirectory(extractRoot); ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true); // 闁告帗绋戠紓鎾绘儎椤旂晫鍨奸梺顔哄妿鐠佹煡鎯勯鑲╃Э Directory.CreateDirectory(targetDeployment); File.WriteAllText(partialMarker, string.Empty); // Apply all files from the extracted payload into the first deployment directory. foreach (var file in fileMap.Files) { ApplyInitialFileEntry(file, targetDeployment, extractRoot); } // 濡ょ姴鐭侀惁澶愬棘閸ワ附顐介柛婵嗙墕缁? foreach (var file in fileMap.Files) { if (!NeedsVerification(file)) { continue; } var fullPath = Path.Combine(targetDeployment, file.Path); var actualHash = ComputeSha256Hex(fullPath); if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException($"File hash mismatch for '{file.Path}'."); } } // Mark the deployment as current and remove the partial marker. var currentMarker = Path.Combine(targetDeployment, ".current"); File.WriteAllText(currentMarker, string.Empty); if (File.Exists(partialMarker)) { File.Delete(partialMarker); } // 婵炴挸鎳愰幃濠囧即鐎涙ɑ鐓€闁? snapshot.Status = "applied"; SaveSnapshot(snapshotPath, snapshot); CleanupIncomingArtifacts(); return new LauncherResult { Success = true, Stage = "update.apply", Code = "ok", Message = $"Initial deployment to {targetVersion}.", CurrentVersion = "0.0.0", TargetVersion = targetVersion }; } catch (Exception ex) { // Clean up the failed target deployment before returning the error result. try { if (Directory.Exists(targetDeployment)) { Directory.Delete(targetDeployment, true); } } catch { } return new LauncherResult { Success = false, Stage = "update.apply", Code = "initial_deploy_failed", Message = "Failed to apply initial deployment.", ErrorMessage = ex.Message, CurrentVersion = "0.0.0", TargetVersion = targetVersion }; } finally { try { if (Directory.Exists(extractRoot)) { Directory.Delete(extractRoot, true); } } catch { } } } /// /// 閹煎瓨姊婚弫銈夊礆濠靛棭娼楅梺顔哄妿鐠佹煡寮崶锔筋偨闁挎稑鐗嗛崣蹇涘棘閺夎法鏆旈悷浣告噹濠р偓闁哄拋鍨界槐婵囩▔瀹ュ浠橀悷鏇氱劍缁噣鎯勯鑲╃Э闁? /// private void ApplyInitialFileEntry(UpdateFileEntry file, string targetDeployment, string extractRoot) { var normalizedPath = NormalizeRelativePath(file.Path); // 闁告帞濞€濞呭酣骞欏鍕▕闁革负鍔岄崣蹇涘棘閺夎法鏆旈悷浣告噺濡炲倽绠涢悾灞炬 if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase)) { return; } var targetPath = Path.Combine(targetDeployment, normalizedPath); EnsurePathWithinRoot(targetPath, targetDeployment); var targetDir = Path.GetDirectoryName(targetPath); if (!string.IsNullOrWhiteSpace(targetDir)) { Directory.CreateDirectory(targetDir); } // 闁哄啰濮鹃鎴﹀及?add 閺夆晜蓱濡?replace闁挎稑鐭傞崗妯荤鎼粹€崇缂傚倵鏅涚€垫ɑ寰勫鍛厬 var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath); var extractedPath = Path.Combine(extractRoot, archiveRelative); EnsurePathWithinRoot(extractedPath, extractRoot); if (!File.Exists(extractedPath)) { throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'."); } File.Copy(extractedPath, targetPath, overwrite: true); } public LauncherResult RollbackLatest() { if (!Directory.Exists(_snapshotsRoot)) { return Failed("update.rollback", "no_snapshot", "No snapshot found."); } var snapshotPath = Directory .EnumerateFiles(_snapshotsRoot, "*.json", SearchOption.TopDirectoryOnly) .OrderByDescending(File.GetCreationTimeUtc) .FirstOrDefault(); if (string.IsNullOrWhiteSpace(snapshotPath)) { return Failed("update.rollback", "no_snapshot", "No snapshot found."); } var snapshot = JsonSerializer.Deserialize(File.ReadAllText(snapshotPath), AppJsonContext.Default.SnapshotMetadata); if (snapshot is null || string.IsNullOrWhiteSpace(snapshot.SourceDirectory)) { return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata."); } if (!Directory.Exists(snapshot.SourceDirectory)) { return Failed("update.rollback", "source_missing", $"Rollback source deployment is missing: {snapshot.SourceDirectory}"); } var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory(); if (string.IsNullOrWhiteSpace(currentDeployment)) { return Failed("update.rollback", "no_current_deployment", "Current deployment not found."); } ActivateDeployment(currentDeployment, snapshot.SourceDirectory); snapshot.Status = "manual_rollback"; SaveSnapshot(snapshotPath, snapshot); return new LauncherResult { Success = true, Stage = "update.rollback", Code = "ok", Message = $"Rolled back to {snapshot.SourceVersion}.", RolledBackTo = snapshot.SourceVersion }; } public void CleanupDestroyedDeployments() { RetainDeploymentsForRollback(); } private void ApplyFileEntry(UpdateFileEntry file, string currentDeployment, string targetDeployment, string extractRoot) { var normalizedPath = NormalizeRelativePath(file.Path); if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase)) { return; } var targetPath = Path.Combine(targetDeployment, normalizedPath); EnsurePathWithinRoot(targetPath, targetDeployment); var targetDir = Path.GetDirectoryName(targetPath); if (!string.IsNullOrWhiteSpace(targetDir)) { Directory.CreateDirectory(targetDir); } if (string.Equals(file.Action, "reuse", StringComparison.OrdinalIgnoreCase)) { var sourcePath = Path.Combine(currentDeployment, normalizedPath); EnsurePathWithinRoot(sourcePath, currentDeployment); if (!File.Exists(sourcePath)) { throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment."); } File.Copy(sourcePath, targetPath, overwrite: true); return; } var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath); var extractedPath = Path.Combine(extractRoot, archiveRelative); EnsurePathWithinRoot(extractedPath, extractRoot); if (!File.Exists(extractedPath)) { throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'."); } File.Copy(extractedPath, targetPath, overwrite: true); } private void ActivateDeployment(string fromDeployment, string toDeployment) { var toCurrent = Path.Combine(toDeployment, ".current"); var fromCurrent = Path.Combine(fromDeployment, ".current"); var fromDestroy = Path.Combine(fromDeployment, ".destroy"); var toDestroy = Path.Combine(toDeployment, ".destroy"); var toPartial = Path.Combine(toDeployment, ".partial"); File.WriteAllText(toCurrent, string.Empty); if (File.Exists(toDestroy)) { File.Delete(toDestroy); } if (File.Exists(fromCurrent)) { File.Delete(fromCurrent); } File.WriteAllText(fromDestroy, string.Empty); if (File.Exists(toPartial)) { File.Delete(toPartial); } } private RollbackAttemptResult TryRollbackOnFailure(SnapshotMetadata snapshot) { try { if (!string.IsNullOrWhiteSpace(snapshot.TargetDirectory) && Directory.Exists(snapshot.TargetDirectory)) { Directory.Delete(snapshot.TargetDirectory, true); } if (string.IsNullOrWhiteSpace(snapshot.SourceDirectory) || !Directory.Exists(snapshot.SourceDirectory)) { return new RollbackAttemptResult(false, "Source deployment is missing."); } if (File.Exists(Path.Combine(snapshot.SourceDirectory, ".destroy"))) { File.Delete(Path.Combine(snapshot.SourceDirectory, ".destroy")); } if (!File.Exists(Path.Combine(snapshot.SourceDirectory, ".current"))) { File.WriteAllText(Path.Combine(snapshot.SourceDirectory, ".current"), string.Empty); } return new RollbackAttemptResult(true, null); } catch (Exception ex) { return new RollbackAttemptResult(false, ex.Message); } } private void RetainDeploymentsForRollback() { _deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); } private sealed record RollbackAttemptResult(bool Success, string? ErrorMessage); internal void CleanupIncomingArtifacts() { foreach (var path in new[] { Path.Combine(_incomingRoot, SignedFileMapName), Path.Combine(_incomingRoot, SignatureFileName), Path.Combine(_incomingRoot, ArchiveFileName), Path.Combine(_incomingRoot, PlondsFileMapName), Path.Combine(_incomingRoot, PlondsSignatureFileName), Path.Combine(_incomingRoot, PlondsUpdateMetadataName), _installCheckpointPath }) { try { if (File.Exists(path)) { File.Delete(path); } } catch { } } foreach (var directory in new[] { Path.Combine(_incomingRoot, PlondsObjectsDirectoryName) }) { try { if (Directory.Exists(directory)) { Directory.Delete(directory, true); } } catch { } } } private (bool Success, string Message) VerifySignature(string payloadPath, string signaturePath, string signatureName) { if (!File.Exists(signaturePath)) { return (false, $"Missing {signatureName}."); } var publicKeyPath = Path.Combine(_launcherRoot, UpdateDirectoryName, PublicKeyFileName); if (!File.Exists(publicKeyPath)) { return (false, $"Missing public key: {publicKeyPath}"); } var payloadBytes = File.ReadAllBytes(payloadPath); var signatureBase64 = File.ReadAllText(signaturePath).Trim(); if (string.IsNullOrWhiteSpace(signatureBase64)) { return (false, "Signature is empty."); } byte[] signature; try { signature = Convert.FromBase64String(signatureBase64); } catch (FormatException) { return (false, "Signature is not valid base64."); } using var rsa = RSA.Create(); rsa.ImportFromPem(File.ReadAllText(publicKeyPath)); var isValid = rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return isValid ? (true, "ok") : (false, "Signature verification failed."); } private static string NormalizeRelativePath(string path) { var normalized = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); return normalized.TrimStart(Path.DirectorySeparatorChar); } private static void EnsurePathWithinRoot(string targetPath, string rootPath) { var fullTarget = Path.GetFullPath(targetPath); var fullRoot = Path.GetFullPath(rootPath); if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException($"Path traversal detected: {targetPath}"); } } private static bool NeedsVerification(UpdateFileEntry file) { return !string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(file.Sha256); } private static string ComputeSha256Hex(string filePath) { using var stream = File.OpenRead(filePath); var hash = SHA256.HashData(stream); return Convert.ToHexString(hash).ToLowerInvariant(); } private static byte[] ComputeSha512(string filePath) { using var stream = File.OpenRead(filePath); return SHA512.HashData(stream); } private static void SaveSnapshot(string path, SnapshotMetadata snapshot) { File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata)); } private InstallCheckpoint? LoadInstallCheckpoint() { if (!File.Exists(_installCheckpointPath)) { return null; } try { var text = File.ReadAllText(_installCheckpointPath); if (string.IsNullOrWhiteSpace(text)) { return null; } return JsonSerializer.Deserialize(text, AppJsonContext.Default.InstallCheckpoint); } catch { return null; } } private void SaveInstallCheckpoint(InstallCheckpoint checkpoint) { File.WriteAllText(_installCheckpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint)); } private void DeleteInstallCheckpoint() { try { if (File.Exists(_installCheckpointPath)) { File.Delete(_installCheckpointPath); } } catch { } } private static LauncherResult Failed(string stage, string code, string message) { return new LauncherResult { Success = false, Stage = stage, Code = code, Message = message, ErrorMessage = message }; } }