82 lines
7.3 KiB
C#
82 lines
7.3 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using Launcher.Next.Core;
|
|
namespace Launcher.Next.Infrastructure;
|
|
|
|
public sealed class Sha256FileHasher : IFileHasher
|
|
{ public async Task<string> Sha256Async(string path, CancellationToken cancellationToken) { await using var s = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(s, cancellationToken)); } }
|
|
public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer, ILastKnownGoodPackageResolver
|
|
{
|
|
private sealed record Pointer(string Current, string? Previous);
|
|
private const string StoredManifestFileName = "package.manifest.json";
|
|
private string PackageRoot(string id) => Path.Combine(root, "Applications", id);
|
|
private string PointerPath(string id) => Path.Combine(PackageRoot(id), "current.json");
|
|
private async Task<Pointer?> ReadPointer(string id, CancellationToken ct)
|
|
{
|
|
if (!File.Exists(PointerPath(id))) return null;
|
|
await using var stream = File.OpenRead(PointerPath(id));
|
|
return await JsonSerializer.DeserializeAsync<Pointer>(stream, cancellationToken: ct);
|
|
}
|
|
public async Task<string?> GetCurrentVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Current;
|
|
public async Task<string?> GetPreviousVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Previous;
|
|
public async Task<ActivationResult> ActivateAsync(string id, string version, CancellationToken ct)
|
|
{
|
|
var dir = VersionDirectory(id, version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir);
|
|
Directory.CreateDirectory(PackageRoot(id)); var prior = await ReadPointer(id, ct);
|
|
var temp = PointerPath(id) + ".tmp"; await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(new Pointer(version, prior?.Current)), ct); File.Move(temp, PointerPath(id), true);
|
|
return new(version, prior?.Current);
|
|
}
|
|
public async Task<string?> RollbackAsync(string id, CancellationToken ct) { var p = await ReadPointer(id, ct); return p?.Previous is null ? null : (await ActivateAsync(id, p.Previous, ct)).CurrentVersion; }
|
|
public async Task<CachedPackageResolution?> ResolveAsync(string packageId, string product, int productYear, string architecture, bool allowOfflineFallback, CancellationToken ct)
|
|
{
|
|
var pointer = await ReadPointer(packageId, ct);
|
|
if (pointer is null) return null;
|
|
var current = await VerifyVersionAsync(packageId, pointer.Current, product, productYear, architecture, ct);
|
|
if (current is not null) return new(packageId, pointer.Current, current, false, "CURRENT_VERIFIED");
|
|
if (!allowOfflineFallback || string.IsNullOrWhiteSpace(pointer.Previous) || string.Equals(pointer.Current, pointer.Previous, StringComparison.OrdinalIgnoreCase)) return null;
|
|
var previous = await VerifyVersionAsync(packageId, pointer.Previous, product, productYear, architecture, ct);
|
|
return previous is null ? null : new(packageId, pointer.Previous, previous, true, "PREVIOUS_VERIFIED_AFTER_CURRENT_REJECTED");
|
|
}
|
|
public async Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress<PackageProgress>? progress = null)
|
|
{
|
|
if (!new PackageManifestValidator().Validate(manifest, manifest.ProductYear, manifest.Architecture).IsValid) throw new InvalidOperationException("MANIFEST_INVALID");
|
|
if (manifest.Files.Any(x => string.Equals(x.Path, StoredManifestFileName, StringComparison.OrdinalIgnoreCase))) throw new InvalidOperationException("MANIFEST_RESERVED_FILE");
|
|
var staging = Path.Combine(root, "Launcher", "staging", manifest.PackageId + "-" + manifest.Version + "-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging);
|
|
try { var i = 0; foreach (var f in manifest.Files) { ct.ThrowIfCancellationRequested(); var source = Path.Combine(sourceRoot, f.Path); var dest = PackageFilePath(staging, f.Path); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(source, dest, true); if (!string.Equals(await hasher.Sha256Async(dest, ct), f.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_MISMATCH"); progress?.Report(new("copy", ++i, manifest.Files.Count)); }
|
|
await File.WriteAllTextAsync(Path.Combine(staging, StoredManifestFileName), JsonSerializer.Serialize(manifest), ct);
|
|
var versionDir = VersionDirectory(manifest.PackageId, manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); if (Directory.Exists(versionDir)) Directory.Delete(staging, true); else Directory.Move(staging, versionDir); return await ActivateAsync(manifest.PackageId, manifest.Version, ct); }
|
|
catch { if (Directory.Exists(staging)) Directory.Delete(staging, true); throw; }
|
|
}
|
|
private async Task<string?> VerifyVersionAsync(string packageId, string version, string product, int productYear, string architecture, CancellationToken ct)
|
|
{
|
|
string versionRoot;
|
|
try { versionRoot = VersionDirectory(packageId, version); }
|
|
catch { return null; }
|
|
var manifestPath = Path.Combine(versionRoot, StoredManifestFileName);
|
|
if (!Directory.Exists(versionRoot) || new DirectoryInfo(versionRoot).Attributes.HasFlag(FileAttributes.ReparsePoint) || !File.Exists(manifestPath) || new FileInfo(manifestPath).Attributes.HasFlag(FileAttributes.ReparsePoint)) return null;
|
|
PackageManifest manifest;
|
|
try { manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, ct)); }
|
|
catch { return null; }
|
|
if (!string.Equals(manifest.PackageId, packageId, StringComparison.Ordinal) || !string.Equals(manifest.Version, version, StringComparison.Ordinal) || !string.Equals(manifest.Product, product, StringComparison.Ordinal) || manifest.ProductYear != productYear || !string.Equals(manifest.Architecture, architecture, StringComparison.OrdinalIgnoreCase) || !new PackageManifestValidator().Validate(manifest, productYear, architecture).IsValid) return null;
|
|
foreach (var file in manifest.Files)
|
|
{
|
|
var path = PackageFilePath(versionRoot, file.Path);
|
|
var info = new FileInfo(path);
|
|
if (!info.Exists || info.Attributes.HasFlag(FileAttributes.ReparsePoint) || info.Length != file.Length || !string.Equals(await hasher.Sha256Async(path, ct), file.Sha256, StringComparison.OrdinalIgnoreCase)) return null;
|
|
}
|
|
return versionRoot;
|
|
}
|
|
private static string PackageFilePath(string packageRoot, string relativePath)
|
|
{
|
|
var root = Path.GetFullPath(packageRoot).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
|
var path = Path.GetFullPath(Path.Combine(root, relativePath));
|
|
if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE");
|
|
return path;
|
|
}
|
|
private string VersionDirectory(string packageId, string version)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(version) || !string.Equals(Path.GetFileName(version), version, StringComparison.Ordinal)) throw new InvalidOperationException("PACKAGE_VERSION_INVALID");
|
|
return PackageFilePath(Path.Combine(PackageRoot(packageId), "versions"), version);
|
|
}
|
|
}
|