changed.对启动器重构的尝试

This commit is contained in:
lincube
2026-05-28 15:14:37 +08:00
parent 1ef47c780b
commit 313d093257
51 changed files with 4509 additions and 2478 deletions

View File

@@ -0,0 +1,73 @@
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator)
{
public void Activate(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);
}
}
public 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.");
}
var destroyMarker = Path.Combine(snapshot.SourceDirectory, ".destroy");
if (File.Exists(destroyMarker))
{
File.Delete(destroyMarker);
}
var currentMarker = Path.Combine(snapshot.SourceDirectory, ".current");
if (!File.Exists(currentMarker))
{
File.WriteAllText(currentMarker, string.Empty);
}
return new RollbackAttemptResult(true, null);
}
catch (Exception ex)
{
return new RollbackAttemptResult(false, ex.Message);
}
}
public void RetainDeploymentsForRollback()
{
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
}
}
internal sealed record RollbackAttemptResult(bool Success, string? ErrorMessage);

View File

@@ -0,0 +1,51 @@
namespace LanMountainDesktop.Launcher.Update;
internal sealed class IncomingArtifactsCleaner(UpdateEnginePaths paths)
{
public void Cleanup()
{
foreach (var path in new[]
{
paths.FileMapPath,
paths.SignaturePath,
paths.ArchivePath,
paths.PlondsFileMapPath,
paths.PlondsSignaturePath,
paths.PlondsUpdateMetadataPath,
paths.InstallCheckpointPath
})
{
TryDeleteFile(path);
}
TryDeleteDirectory(paths.PlondsObjectsRoot);
}
private static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
}
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, true);
}
}
catch
{
}
}
}

View File

@@ -0,0 +1,49 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class InstallCheckpointStore(UpdateEnginePaths paths)
{
public InstallCheckpoint? Load()
{
if (!File.Exists(paths.InstallCheckpointPath))
{
return null;
}
try
{
var text = File.ReadAllText(paths.InstallCheckpointPath);
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return JsonSerializer.Deserialize(text, AppJsonContext.Default.InstallCheckpoint);
}
catch
{
return null;
}
}
public void Save(InstallCheckpoint checkpoint)
{
File.WriteAllText(paths.InstallCheckpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint));
}
public void Delete()
{
try
{
if (File.Exists(paths.InstallCheckpointPath))
{
File.Delete(paths.InstallCheckpointPath);
}
}
catch
{
}
}
}

View File

@@ -0,0 +1,287 @@
using System.IO.Compression;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class LegacyUpdateApplier(
DeploymentLocator deploymentLocator,
UpdateEnginePaths paths,
UpdateSignatureVerifier signatureVerifier,
IUpdateProgressReporter progressReporter,
UpdateSnapshotStore snapshotStore,
InstallCheckpointStore checkpointStore,
DeploymentActivator deploymentActivator,
IncomingArtifactsCleaner incomingCleaner)
{
public async Task<LauncherResult> ApplyAsync()
{
if (!File.Exists(paths.FileMapPath) || !File.Exists(paths.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 = signatureVerifier.Verify(paths.FileMapPath, paths.SignaturePath, UpdateEnginePaths.SignatureFileName);
if (!verifyResult.Success)
{
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
return UpdateEngineResults.Failed("update.apply", "signature_failed", verifyResult.Message);
}
var fileMapText = await File.ReadAllTextAsync(paths.FileMapPath).ConfigureAwait(false);
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 UpdateEngineResults.Failed("update.apply", "invalid_manifest", "No update file entries were found.");
}
var currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory();
var currentVersion = deploymentLocator.GetCurrentVersion();
if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) &&
!string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase))
{
return UpdateEngineResults.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 = checkpointStore.Load();
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 UpdateEngineResults.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 snapshot = BuildSnapshot(canResume, existingCheckpoint, currentVersion, targetVersion, currentDeployment, targetDeployment);
var snapshotPath = snapshotStore.CreateSnapshotPath(snapshot.SnapshotId);
var checkpoint = canResume
? existingCheckpoint!
: BuildCheckpoint(snapshot, currentVersion, targetVersion, currentDeployment, targetDeployment);
try
{
snapshotStore.Save(snapshotPath, snapshot);
PrepareExtractRoot();
ZipFile.ExtractToDirectory(paths.ArchivePath, paths.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(Path.Combine(targetDeployment, ".partial"), string.Empty);
}
checkpointStore.Save(checkpoint);
ApplyFiles(fileMap, currentDeployment!, targetDeployment, checkpoint);
VerifyFiles(fileMap, targetDeployment, checkpoint);
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileMap.Files.Count, fileMap.Files.Count));
deploymentActivator.Activate(currentDeployment!, targetDeployment);
snapshot.Status = "applied";
snapshotStore.Save(snapshotPath, snapshot);
incomingCleaner.Cleanup();
deploymentActivator.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 = deploymentActivator.TryRollbackOnFailure(snapshot);
snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed";
snapshotStore.Save(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
{
checkpointStore.Delete();
TryDeleteExtractRoot();
}
}
private void ApplyFiles(SignedFileMap fileMap, string currentDeployment, string targetDeployment, InstallCheckpoint 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);
checkpoint.AppliedCount = fileIndex + 1;
checkpointStore.Save(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));
}
}
private void VerifyFiles(SignedFileMap fileMap, string targetDeployment, InstallCheckpoint checkpoint)
{
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))
{
var fullPath = Path.Combine(targetDeployment, file.Path);
var actualHash = UpdateHash.ComputeSha256Hex(fullPath);
if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
}
}
checkpoint.VerifiedCount = verifyIndex + 1;
checkpointStore.Save(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));
}
}
private void ApplyFileEntry(UpdateFileEntry file, string currentDeployment, string targetDeployment)
{
var normalizedPath = UpdatePathGuard.NormalizeRelativePath(file.Path);
if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase))
{
return;
}
var targetPath = Path.Combine(targetDeployment, normalizedPath);
UpdatePathGuard.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);
UpdatePathGuard.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 : UpdatePathGuard.NormalizeRelativePath(file.ArchivePath);
var extractedPath = Path.Combine(paths.ExtractRoot, archiveRelative);
UpdatePathGuard.EnsurePathWithinRoot(extractedPath, paths.ExtractRoot);
if (!File.Exists(extractedPath))
{
throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'.");
}
File.Copy(extractedPath, targetPath, overwrite: true);
}
private void PrepareExtractRoot()
{
if (Directory.Exists(paths.ExtractRoot))
{
Directory.Delete(paths.ExtractRoot, true);
}
Directory.CreateDirectory(paths.ExtractRoot);
}
private void TryDeleteExtractRoot()
{
try
{
if (Directory.Exists(paths.ExtractRoot))
{
Directory.Delete(paths.ExtractRoot, true);
}
}
catch
{
}
}
private static SnapshotMetadata BuildSnapshot(
bool canResume,
InstallCheckpoint? existingCheckpoint,
string currentVersion,
string targetVersion,
string? currentDeployment,
string targetDeployment) =>
new()
{
SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"),
SourceVersion = currentVersion,
TargetVersion = targetVersion,
CreatedAt = DateTimeOffset.UtcNow,
SourceDirectory = currentDeployment ?? string.Empty,
TargetDirectory = targetDeployment,
Status = "pending"
};
private static InstallCheckpoint BuildCheckpoint(
SnapshotMetadata snapshot,
string currentVersion,
string targetVersion,
string? currentDeployment,
string targetDeployment) =>
new()
{
SnapshotId = snapshot.SnapshotId,
SourceVersion = currentVersion,
TargetVersion = targetVersion,
SourceDirectory = currentDeployment,
TargetDirectory = targetDeployment,
IsInitialDeployment = false
};
private static bool NeedsVerification(UpdateFileEntry file)
{
return !string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(file.Sha256);
}
}

View File

@@ -0,0 +1,116 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class PendingUpdateDetector(
DeploymentLocator deploymentLocator,
UpdateEnginePaths paths,
UpdateSignatureVerifier signatureVerifier)
{
public LauncherResult CheckPendingUpdate()
{
if (File.Exists(paths.PlondsFileMapPath) && File.Exists(paths.PlondsSignaturePath))
{
var pdcFileMapText = File.ReadAllText(paths.PlondsFileMapPath);
var pdcFileMap = JsonSerializer.Deserialize(pdcFileMapText, AppJsonContext.Default.PlondsFileMap);
if (pdcFileMap is null)
{
return UpdateEngineResults.Failed("update.check", "invalid_manifest", "plonds-filemap.json is invalid.");
}
var pdcVerified = signatureVerifier.Verify(
paths.PlondsFileMapPath,
paths.PlondsSignaturePath,
UpdateEnginePaths.PlondsSignatureFileName);
if (!pdcVerified.Success)
{
return UpdateEngineResults.Failed("update.check", "signature_failed", pdcVerified.Message);
}
var pdcMetadata = PlondsManifestParser.LoadMetadata(paths.PlondsUpdateMetadataPath);
return new LauncherResult
{
Success = true,
Stage = "update.check",
Code = "available",
Message = "Pending PLONDS update is available.",
CurrentVersion = deploymentLocator.GetCurrentVersion(),
TargetVersion = PlondsManifestParser.ResolveTargetVersion(pdcFileMap, pdcMetadata)
};
}
if (!File.Exists(paths.FileMapPath) || !File.Exists(paths.ArchivePath))
{
return new LauncherResult
{
Success = true,
Stage = "update.check",
Code = "noop",
Message = "No pending update."
};
}
var fileMapText = File.ReadAllText(paths.FileMapPath);
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
if (fileMap is null)
{
return UpdateEngineResults.Failed("update.check", "invalid_manifest", "files.json is invalid.");
}
var verified = signatureVerifier.Verify(paths.FileMapPath, paths.SignaturePath, UpdateEnginePaths.SignatureFileName);
if (!verified.Success)
{
return UpdateEngineResults.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 LauncherResult ValidateIncomingState()
{
if (File.Exists(paths.ApplyLockPath))
{
return UpdateEngineResults.Failed("update.apply", "lock_conflict", "Another update apply operation is already in progress.");
}
if (!File.Exists(paths.DeploymentLockPath))
{
return UpdateEngineResults.Failed("update.apply", "staging_incomplete", "Deployment lock is missing. Please redownload the update.");
}
var hasPlondsMap = File.Exists(paths.PlondsFileMapPath);
var hasLegacyMap = File.Exists(paths.FileMapPath);
if (hasPlondsMap && !File.Exists(paths.DownloadMarkerPath))
{
return UpdateEngineResults.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."
};
}
}

View File

@@ -0,0 +1,416 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
internal static class PlondsManifestParser
{
public static List<PlondsFileEntry> CollectFileEntries(PlondsFileMap fileMap)
{
var files = new List<PlondsFileEntry>();
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;
}
public static void PopulateFromRawJson(string fileMapJson, PlondsFileMap fileMap, ICollection<PlondsFileEntry> files)
{
if (string.IsNullOrWhiteSpace(fileMapJson))
{
return;
}
using var document = JsonDocument.Parse(fileMapJson);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return;
}
fileMap.FromVersion ??= ReadStringIgnoreCase(root, "fromversion");
fileMap.ToVersion ??= ReadStringIgnoreCase(root, "toversion");
fileMap.Version ??= ReadStringIgnoreCase(root, "version");
fileMap.Platform ??= ReadStringIgnoreCase(root, "platform");
fileMap.Arch ??= ReadStringIgnoreCase(root, "arch");
fileMap.DistributionId ??= ReadStringIgnoreCase(root, "distributionid");
PopulateMetadata(root, fileMap.Metadata);
if (TryGetPropertyIgnoreCase(root, "files", out var rootFilesNode))
{
ParseFilesNode(rootFilesNode, null, files);
}
if (TryGetPropertyIgnoreCase(root, "components", out var componentsNode))
{
ParseComponentsNode(componentsNode, files);
}
}
public static PlondsUpdateMetadata? LoadMetadata(string path)
{
if (!File.Exists(path))
{
return null;
}
try
{
var text = File.ReadAllText(path);
return string.IsNullOrWhiteSpace(text)
? null
: JsonSerializer.Deserialize(text, AppJsonContext.Default.PlondsUpdateMetadata);
}
catch
{
return null;
}
}
public static string? ResolveSourceVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata)
{
return FirstNonEmpty(
metadata?.FromVersion,
fileMap.FromVersion,
TryGetMetadataValue(fileMap.Metadata, "fromVersion"),
TryGetMetadataValue(fileMap.Metadata, "sourceVersion"));
}
public static string? ResolveTargetVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata)
{
return FirstNonEmpty(
metadata?.ToVersion,
fileMap.ToVersion,
fileMap.Version,
TryGetMetadataValue(fileMap.Metadata, "toVersion"),
TryGetMetadataValue(fileMap.Metadata, "targetVersion"));
}
public 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)) &&
UpdateHash.TryParseHashBytes(file.Hash.Value, out expected))
{
return true;
}
}
if (UpdateHash.TryParseHashBytes(file.Sha512, out expected))
{
return true;
}
return UpdateHash.TryParseHashBytes(file.Sha512Base64, out expected);
}
public 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 UpdateHash.TryParseHashBytes(file.Hash.Value, out expected);
}
private static void ParseComponentsNode(JsonElement componentsNode, ICollection<PlondsFileEntry> files)
{
if (componentsNode.ValueKind == JsonValueKind.Object)
{
foreach (var component in componentsNode.EnumerateObject())
{
if (component.Value.ValueKind == JsonValueKind.Object &&
TryGetPropertyIgnoreCase(component.Value, "files", out var componentFilesNode))
{
ParseFilesNode(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 = ReadStringIgnoreCase(component, "name");
if (TryGetPropertyIgnoreCase(component, "files", out var componentFilesNode))
{
ParseFilesNode(componentFilesNode, componentName, files);
}
}
}
private static void ParseFilesNode(JsonElement filesNode, string? componentName, ICollection<PlondsFileEntry> files)
{
if (filesNode.ValueKind == JsonValueKind.Object)
{
foreach (var fileEntry in filesNode.EnumerateObject())
{
if (fileEntry.Value.ValueKind == JsonValueKind.Object &&
TryCreateFileEntry(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 &&
TryCreateFileEntry(ReadStringIgnoreCase(fileEntry, "path"), componentName, fileEntry, out var parsed))
{
files.Add(parsed);
}
}
}
private static bool TryCreateFileEntry(string? fallbackPath, string? componentName, JsonElement node, out PlondsFileEntry entry)
{
entry = new PlondsFileEntry();
var path = ReadStringIgnoreCase(node, "path");
if (string.IsNullOrWhiteSpace(path))
{
path = fallbackPath;
}
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
var archiveSha512 = ReadByteArrayIgnoreCase(node, "archivesha512");
var archiveSha512Text = ReadStringIgnoreCase(node, "archivesha512");
entry = new PlondsFileEntry
{
Path = path,
Action = FirstNonEmpty(ReadStringIgnoreCase(node, "action"), "replace"),
Url = ReadStringIgnoreCase(node, "archivedownloadurl") ?? ReadStringIgnoreCase(node, "downloadurl") ?? ReadStringIgnoreCase(node, "url"),
ObjectUrl = ReadStringIgnoreCase(node, "objecturl"),
ObjectPath = ReadStringIgnoreCase(node, "objectpath") ?? ReadStringIgnoreCase(node, "archivepath"),
ObjectKey = ReadStringIgnoreCase(node, "objectkey"),
ArchivePath = ReadStringIgnoreCase(node, "archivepath"),
Sha256 = ReadStringIgnoreCase(node, "sha256") ?? ReadStringIgnoreCase(node, "filesha256"),
Sha512 = ReadStringIgnoreCase(node, "filesha512") ?? ReadStringIgnoreCase(node, "sha512"),
Sha512Bytes = ReadByteArrayIgnoreCase(node, "filesha512") ?? ReadByteArrayIgnoreCase(node, "sha512"),
Metadata = BuildMetadata(node, componentName)
};
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 (TryGetPropertyIgnoreCase(node, "hash", out var hashNode) && hashNode.ValueKind == JsonValueKind.Object)
{
entry.Hash = new PlondsHashDescriptor
{
Algorithm = ReadStringIgnoreCase(hashNode, "algorithm"),
Value = ReadStringIgnoreCase(hashNode, "value"),
Bytes = ReadByteArrayIgnoreCase(hashNode, "bytes")
};
}
return true;
}
private static Dictionary<string, string> BuildMetadata(JsonElement node, string? componentName)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(componentName))
{
metadata["component"] = componentName;
}
PopulateMetadata(node, metadata);
return metadata;
}
private static void PopulateMetadata(JsonElement node, Dictionary<string, string> metadata)
{
if (!TryGetPropertyIgnoreCase(node, "metadata", out var metadataNode) ||
metadataNode.ValueKind != JsonValueKind.Object)
{
return;
}
foreach (var property in metadataNode.EnumerateObject())
{
if (property.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
continue;
}
var value = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString()
: property.Value.ToString();
if (!string.IsNullOrWhiteSpace(value))
{
metadata[property.Name] = value;
}
}
}
private static bool TryGetPropertyIgnoreCase(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? ReadStringIgnoreCase(JsonElement node, string propertyName)
{
if (!TryGetPropertyIgnoreCase(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[]? ReadByteArrayIgnoreCase(JsonElement node, string propertyName)
{
return TryGetPropertyIgnoreCase(node, propertyName, out var value)
? ParseByteArrayValue(value)
: null;
}
private static byte[]? ParseByteArrayValue(JsonElement value)
{
if (value.ValueKind == JsonValueKind.String)
{
return UpdateHash.TryParseHashBytes(value.GetString(), out var parsed) ? parsed : null;
}
if (value.ValueKind != JsonValueKind.Array)
{
return null;
}
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;
}
private static string? TryGetMetadataValue(Dictionary<string, string>? 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) &&
!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;
}
}

View File

@@ -0,0 +1,97 @@
using System.IO.Compression;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class PlondsPayloadResolver(UpdateEnginePaths paths)
{
public string ResolveObjectPath(PlondsFileEntry file)
{
var candidates = new List<string>();
AddPathCandidates(candidates, file.ObjectPath);
AddPathCandidates(candidates, file.ObjectKey);
AddPathCandidates(candidates, file.ArchivePath);
AddPathCandidates(candidates, file.ObjectUrl);
AddPathCandidates(candidates, file.Url);
if (PlondsManifestParser.TryGetExpectedObjectSha512(file, out var expectedSha512) ||
PlondsManifestParser.TryGetExpectedSha512(file, out expectedSha512))
{
var hashHex = Convert.ToHexString(expectedSha512).ToLowerInvariant();
AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex));
if (hashHex.Length > 2)
{
AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex));
AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex[2..]));
}
AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, $"{hashHex}.gz"));
}
foreach (var relativePath in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
{
var fullPath = Path.GetFullPath(Path.Combine(paths.IncomingRoot, relativePath));
if (!fullPath.StartsWith(Path.GetFullPath(paths.IncomingRoot), StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (File.Exists(fullPath))
{
return fullPath;
}
}
throw new FileNotFoundException($"Unable to resolve object payload for '{file.Path}'.");
}
public 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 static void AddPathCandidates(ICollection<string> 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($"{UpdateEnginePaths.PlondsObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
{
candidates.Add(Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, normalized));
}
var fileName = Path.GetFileName(normalized);
if (!string.IsNullOrWhiteSpace(fileName))
{
candidates.Add(Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, fileName));
}
}
}

View File

@@ -0,0 +1,374 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class PlondsUpdateApplier(
DeploymentLocator deploymentLocator,
UpdateEnginePaths paths,
UpdateSignatureVerifier signatureVerifier,
IUpdateProgressReporter progressReporter,
UpdateSnapshotStore snapshotStore,
InstallCheckpointStore checkpointStore,
DeploymentActivator deploymentActivator,
IncomingArtifactsCleaner incomingCleaner,
PlondsPayloadResolver payloadResolver)
{
public async Task<LauncherResult> ApplyAsync()
{
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying PLONDS signature...", 0, null, 0, 0));
var verifyResult = signatureVerifier.Verify(paths.PlondsFileMapPath, paths.PlondsSignaturePath, UpdateEnginePaths.PlondsSignatureFileName);
if (!verifyResult.Success)
{
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
return UpdateEngineResults.Failed("update.apply", "signature_failed", verifyResult.Message);
}
var fileMapText = await File.ReadAllTextAsync(paths.PlondsFileMapPath).ConfigureAwait(false);
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.PlondsFileMap) ?? new PlondsFileMap();
var fileEntries = PlondsManifestParser.CollectFileEntries(fileMap);
if (fileEntries.Count == 0)
{
PlondsManifestParser.PopulateFromRawJson(fileMapText, fileMap, fileEntries);
}
if (fileEntries.Count == 0)
{
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No PLONDS file entries were found.", false));
return UpdateEngineResults.Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found.");
}
var pdcMetadata = PlondsManifestParser.LoadMetadata(paths.PlondsUpdateMetadataPath);
var currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory();
var currentVersion = deploymentLocator.GetCurrentVersion();
var sourceVersion = string.IsNullOrWhiteSpace(currentVersion) ? "0.0.0" : currentVersion;
var expectedSourceVersion = PlondsManifestParser.ResolveSourceVersion(fileMap, pdcMetadata);
if (!string.IsNullOrWhiteSpace(expectedSourceVersion) &&
!string.Equals(expectedSourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase))
{
return UpdateEngineResults.Failed(
"update.apply",
"version_mismatch",
$"PLONDS update requires source version {expectedSourceVersion} but current is {sourceVersion}.");
}
var targetVersion = PlondsManifestParser.ResolveTargetVersion(fileMap, pdcMetadata);
if (string.IsNullOrWhiteSpace(targetVersion))
{
targetVersion = sourceVersion;
}
var isInitialDeployment = string.IsNullOrWhiteSpace(currentDeployment);
var existingCheckpoint = checkpointStore.Load();
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 UpdateEngineResults.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 snapshot = BuildSnapshot(canResume, existingCheckpoint, sourceVersion, targetVersion, currentDeployment, targetDeployment);
var snapshotPath = snapshotStore.CreateSnapshotPath(snapshot.SnapshotId);
var checkpoint = canResume
? existingCheckpoint!
: BuildCheckpoint(snapshot, sourceVersion, targetVersion, currentDeployment, targetDeployment, isInitialDeployment);
try
{
snapshotStore.Save(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(Path.Combine(targetDeployment, ".partial"), string.Empty);
}
checkpointStore.Save(checkpoint);
ApplyFiles(fileEntries, currentDeployment, targetDeployment, checkpoint);
VerifyFiles(fileEntries, targetDeployment, checkpoint);
if (isInitialDeployment)
{
File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty);
var partialMarker = Path.Combine(targetDeployment, ".partial");
if (File.Exists(partialMarker))
{
File.Delete(partialMarker);
}
}
else
{
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.Count));
deploymentActivator.Activate(currentDeployment!, targetDeployment);
}
snapshot.Status = "applied";
snapshotStore.Save(snapshotPath, snapshot);
incomingCleaner.Cleanup();
deploymentActivator.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)
{
return HandleFailure(ex, isInitialDeployment, targetDeployment, snapshot, snapshotPath, sourceVersion, targetVersion);
}
finally
{
checkpointStore.Delete();
}
}
private void ApplyFiles(IReadOnlyList<PlondsFileEntry> fileEntries, string? currentDeployment, string targetDeployment, InstallCheckpoint 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];
ApplyFileEntry(entry, currentDeployment, targetDeployment);
checkpoint.AppliedCount = fileIndex + 1;
checkpointStore.Save(checkpoint);
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (checkpoint.AppliedCount * 30 / fileEntries.Count), entry.Path, checkpoint.AppliedCount, fileEntries.Count));
}
}
private void VerifyFiles(IReadOnlyList<PlondsFileEntry> fileEntries, string targetDeployment, InstallCheckpoint checkpoint)
{
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];
VerifyFileEntry(entry, targetDeployment);
checkpoint.VerifiedCount = verifyIndex + 1;
checkpointStore.Save(checkpoint);
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileEntries.Count), entry.Path, checkpoint.VerifiedCount, fileEntries.Count));
}
}
private void ApplyFileEntry(PlondsFileEntry file, string? currentDeployment, string targetDeployment)
{
var normalizedPath = UpdatePathGuard.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);
UpdatePathGuard.EnsurePathWithinRoot(targetPath, targetDeployment);
var targetDir = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetDir))
{
Directory.CreateDirectory(targetDir);
}
if (string.Equals(action, "reuse", StringComparison.OrdinalIgnoreCase))
{
CopyReusedFile(file, currentDeployment, normalizedPath, targetPath);
return;
}
var objectPath = payloadResolver.ResolveObjectPath(file);
var objectBytes = File.ReadAllBytes(objectPath);
var restoredBytes = PlondsPayloadResolver.TryInflateGzip(objectBytes) ?? objectBytes;
File.WriteAllBytes(targetPath, restoredBytes);
ApplyUnixFileModeIfPresent(targetPath, file);
}
private static void CopyReusedFile(PlondsFileEntry file, string? currentDeployment, string normalizedPath, string targetPath)
{
if (string.IsNullOrWhiteSpace(currentDeployment))
{
throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because no source deployment is available.");
}
var sourcePath = Path.Combine(currentDeployment, normalizedPath);
UpdatePathGuard.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);
}
private static void VerifyFileEntry(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, UpdatePathGuard.NormalizeRelativePath(file.Path));
UpdatePathGuard.EnsurePathWithinRoot(targetPath, targetDeployment);
if (!File.Exists(targetPath))
{
throw new FileNotFoundException($"Expected target file was not created: {file.Path}");
}
if (PlondsManifestParser.TryGetExpectedSha512(file, out var expectedSha512))
{
var actualSha512 = UpdateHash.ComputeSha512(targetPath);
if (!actualSha512.AsSpan().SequenceEqual(expectedSha512))
{
throw new InvalidOperationException($"SHA-512 mismatch for '{file.Path}'.");
}
return;
}
if (!string.IsNullOrWhiteSpace(file.Sha256))
{
var expectedSha256 = UpdateHash.NormalizeHashText(file.Sha256);
var actualSha256 = UpdateHash.ComputeSha256Hex(targetPath);
if (!string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"SHA-256 mismatch for '{file.Path}'.");
}
}
}
private LauncherResult HandleFailure(
Exception ex,
bool isInitialDeployment,
string targetDeployment,
SnapshotMetadata snapshot,
string snapshotPath,
string sourceVersion,
string targetVersion)
{
if (isInitialDeployment)
{
TryDeleteDirectory(targetDeployment);
snapshot.Status = "failed";
snapshotStore.Save(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 = deploymentActivator.TryRollbackOnFailure(snapshot);
snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed";
snapshotStore.Save(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
};
}
private static SnapshotMetadata BuildSnapshot(
bool canResume,
InstallCheckpoint? existingCheckpoint,
string sourceVersion,
string targetVersion,
string? currentDeployment,
string targetDeployment) =>
new()
{
SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"),
SourceVersion = sourceVersion,
TargetVersion = targetVersion,
CreatedAt = DateTimeOffset.UtcNow,
SourceDirectory = currentDeployment ?? string.Empty,
TargetDirectory = targetDeployment,
Status = "pending"
};
private static InstallCheckpoint BuildCheckpoint(
SnapshotMetadata snapshot,
string sourceVersion,
string targetVersion,
string? currentDeployment,
string targetDeployment,
bool isInitialDeployment) =>
new()
{
SnapshotId = snapshot.SnapshotId,
SourceVersion = sourceVersion,
TargetVersion = targetVersion,
SourceDirectory = currentDeployment,
TargetDirectory = targetDeployment,
IsInitialDeployment = isInitialDeployment
};
private static void ApplyUnixFileModeIfPresent(string targetPath, PlondsFileEntry file)
{
if (OperatingSystem.IsWindows() ||
!file.Metadata.TryGetValue("unixFileMode", out var rawMode) ||
string.IsNullOrWhiteSpace(rawMode))
{
return;
}
try
{
var modeValue = Convert.ToInt32(rawMode.Trim(), 8);
File.SetUnixFileMode(targetPath, (UnixFileMode)modeValue);
}
catch
{
}
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, true);
}
}
catch
{
}
}
}

View File

@@ -0,0 +1,48 @@
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class RollbackStrategy(
DeploymentLocator deploymentLocator,
UpdateSnapshotStore snapshotStore,
DeploymentActivator deploymentActivator)
{
public LauncherResult RollbackLatest()
{
var latest = snapshotStore.LoadLatest();
if (latest is null)
{
return UpdateEngineResults.Failed("update.rollback", "no_snapshot", "No snapshot found.");
}
var (snapshotPath, snapshot) = latest.Value;
if (string.IsNullOrWhiteSpace(snapshot.SourceDirectory))
{
return UpdateEngineResults.Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata.");
}
if (!Directory.Exists(snapshot.SourceDirectory))
{
return UpdateEngineResults.Failed("update.rollback", "source_missing", $"Rollback source deployment is missing: {snapshot.SourceDirectory}");
}
var currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(currentDeployment))
{
return UpdateEngineResults.Failed("update.rollback", "no_current_deployment", "Current deployment not found.");
}
deploymentActivator.Activate(currentDeployment, snapshot.SourceDirectory);
snapshot.Status = "manual_rollback";
snapshotStore.Save(snapshotPath, snapshot);
return new LauncherResult
{
Success = true,
Stage = "update.rollback",
Code = "ok",
Message = $"Rolled back to {snapshot.SourceVersion}.",
RolledBackTo = snapshot.SourceVersion
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
namespace LanMountainDesktop.Launcher.Update;
internal static class UpdateEngineFactory
{
public static IUpdateEngine Create(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null) =>
new UpdateEngineFacade(deploymentLocator, progressReporter);
}

View File

@@ -0,0 +1,68 @@
using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class UpdateEnginePaths
{
public const string UpdateDirectoryName = "update";
public const string IncomingDirectoryName = "incoming";
public const string SnapshotsDirectoryName = "snapshots";
public const string SignedFileMapName = "files.json";
public const string SignatureFileName = "files.json.sig";
public const string ArchiveFileName = "update.zip";
public const string PlondsFileMapName = "plonds-filemap.json";
public const string PlondsSignatureFileName = "plonds-filemap.sig";
public const string PlondsUpdateMetadataName = "plonds-update.json";
public const string PlondsObjectsDirectoryName = "objects";
public const string PublicKeyFileName = "public-key.pem";
public UpdateEnginePaths(string appRoot)
{
AppRoot = appRoot;
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 string AppRoot { get; }
public string LauncherRoot { get; }
public string IncomingRoot { get; }
public string SnapshotsRoot { get; }
public string InstallCheckpointPath { get; }
public string ApplyLockPath => ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(AppRoot);
public string DeploymentLockPath => ContractsUpdate.UpdatePaths.GetDeploymentLockPath(AppRoot);
public string DownloadMarkerPath => ContractsUpdate.UpdatePaths.GetDownloadMarkerPath(AppRoot);
public string FileMapPath => Path.Combine(IncomingRoot, SignedFileMapName);
public string SignaturePath => Path.Combine(IncomingRoot, SignatureFileName);
public string ArchivePath => Path.Combine(IncomingRoot, ArchiveFileName);
public string PlondsFileMapPath => Path.Combine(IncomingRoot, PlondsFileMapName);
public string PlondsSignaturePath => Path.Combine(IncomingRoot, PlondsSignatureFileName);
public string PlondsUpdateMetadataPath => Path.Combine(IncomingRoot, PlondsUpdateMetadataName);
public string PlondsObjectsRoot => Path.Combine(IncomingRoot, PlondsObjectsDirectoryName);
public string PublicKeyPath => Path.Combine(LauncherRoot, UpdateDirectoryName, PublicKeyFileName);
public string ExtractRoot => Path.Combine(IncomingRoot, "extracted");
public bool HasPlondsPayload => File.Exists(PlondsFileMapPath) && File.Exists(PlondsSignaturePath);
public bool HasLegacyPayload => File.Exists(FileMapPath) && File.Exists(ArchivePath);
public string GetSnapshotPath(string snapshotId) => Path.Combine(SnapshotsRoot, $"{snapshotId}.json");
}

View File

@@ -0,0 +1,18 @@
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
internal static class UpdateEngineResults
{
public static LauncherResult Failed(string stage, string code, string message)
{
return new LauncherResult
{
Success = false,
Stage = stage,
Code = code,
Message = message,
ErrorMessage = message
};
}
}

View File

@@ -0,0 +1,84 @@
using System.Security.Cryptography;
namespace LanMountainDesktop.Launcher.Update;
internal static class UpdateHash
{
public static string ComputeSha256Hex(string filePath)
{
using var stream = File.OpenRead(filePath);
var hash = SHA256.HashData(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static byte[] ComputeSha512(string filePath)
{
using var stream = File.OpenRead(filePath);
return SHA512.HashData(stream);
}
public 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;
}
}
public 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 bool IsHexString(string value)
{
foreach (var ch in value)
{
if (!Uri.IsHexDigit(ch))
{
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,20 @@
namespace LanMountainDesktop.Launcher.Update;
internal static class UpdatePathGuard
{
public static string NormalizeRelativePath(string path)
{
var normalized = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
return normalized.TrimStart(Path.DirectorySeparatorChar);
}
public 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}");
}
}
}

View File

@@ -0,0 +1,41 @@
using System.Security.Cryptography;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class UpdateSignatureVerifier(UpdateEnginePaths paths)
{
public (bool Success, string Message) Verify(string payloadPath, string signaturePath, string signatureName)
{
if (!File.Exists(signaturePath))
{
return (false, $"Missing {signatureName}.");
}
if (!File.Exists(paths.PublicKeyPath))
{
return (false, $"Missing public key: {paths.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(paths.PublicKeyPath));
var isValid = rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
return isValid ? (true, "ok") : (false, "Signature verification failed.");
}
}

View File

@@ -0,0 +1,34 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class UpdateSnapshotStore(UpdateEnginePaths paths)
{
public string CreateSnapshotPath(string snapshotId) => paths.GetSnapshotPath(snapshotId);
public void Save(string path, SnapshotMetadata snapshot)
{
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
}
public (string Path, SnapshotMetadata Snapshot)? LoadLatest()
{
if (!Directory.Exists(paths.SnapshotsRoot))
{
return null;
}
var snapshotPath = Directory
.EnumerateFiles(paths.SnapshotsRoot, "*.json", SearchOption.TopDirectoryOnly)
.OrderByDescending(File.GetCreationTimeUtc)
.FirstOrDefault();
if (string.IsNullOrWhiteSpace(snapshotPath))
{
return null;
}
var snapshot = JsonSerializer.Deserialize(File.ReadAllText(snapshotPath), AppJsonContext.Default.SnapshotMetadata);
return snapshot is null ? null : (snapshotPath, snapshot);
}
}