feat: verify last-known-good local packages
This commit is contained in:
@@ -27,6 +27,8 @@ public interface IPackageSynchronizer { Task<ActivationResult> SynchronizeAsync(
|
|||||||
public interface ILocalPackageStore { Task<string?> GetCurrentVersionAsync(string packageId, CancellationToken cancellationToken); Task<string?> GetPreviousVersionAsync(string packageId, CancellationToken cancellationToken); }
|
public interface ILocalPackageStore { Task<string?> GetCurrentVersionAsync(string packageId, CancellationToken cancellationToken); Task<string?> GetPreviousVersionAsync(string packageId, CancellationToken cancellationToken); }
|
||||||
public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
|
public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
|
||||||
public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); }
|
public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); }
|
||||||
|
public sealed record CachedPackageResolution(string PackageId, string Version, string LocalPackageRoot, bool IsOfflineFallback, string Reason);
|
||||||
|
public interface ILastKnownGoodPackageResolver { Task<CachedPackageResolution?> ResolveAsync(string packageId, string product, int productYear, string architecture, bool allowOfflineFallback, CancellationToken cancellationToken); }
|
||||||
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
|
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
|
||||||
public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string FixtureModelDwg, string AutomationScriptPath, bool Diagnostic, bool Apply);
|
public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string FixtureModelDwg, string AutomationScriptPath, bool Diagnostic, bool Apply);
|
||||||
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(ResolvedAutomationScriptPaths paths); }
|
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(ResolvedAutomationScriptPaths paths); }
|
||||||
|
|||||||
@@ -5,27 +5,77 @@ namespace Launcher.Next.Infrastructure;
|
|||||||
|
|
||||||
public sealed class Sha256FileHasher : IFileHasher
|
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 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
|
public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer, ILastKnownGoodPackageResolver
|
||||||
{
|
{
|
||||||
private sealed record Pointer(string Current, string? Previous);
|
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 PackageRoot(string id) => Path.Combine(root, "Applications", id);
|
||||||
private string PointerPath(string id) => Path.Combine(PackageRoot(id), "current.json");
|
private string PointerPath(string id) => Path.Combine(PackageRoot(id), "current.json");
|
||||||
private async Task<Pointer?> ReadPointer(string id, CancellationToken ct) => File.Exists(PointerPath(id)) ? await JsonSerializer.DeserializeAsync<Pointer>(File.OpenRead(PointerPath(id)), cancellationToken: ct) : null;
|
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?> 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<string?> GetPreviousVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Previous;
|
||||||
public async Task<ActivationResult> ActivateAsync(string id, string version, CancellationToken ct)
|
public async Task<ActivationResult> ActivateAsync(string id, string version, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var dir = Path.Combine(PackageRoot(id), "versions", version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir);
|
var dir = VersionDirectory(id, version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir);
|
||||||
Directory.CreateDirectory(PackageRoot(id)); var prior = await ReadPointer(id, ct);
|
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);
|
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);
|
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<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)
|
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);
|
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 = Path.GetFullPath(Path.Combine(staging, f.Path)); if (!dest.StartsWith(Path.GetFullPath(staging) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE"); 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)); }
|
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)); }
|
||||||
var versionDir = Path.Combine(PackageRoot(manifest.PackageId), "versions", 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); }
|
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; }
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,13 @@
|
|||||||
Before changing package pointers, Launcher Next writes an activation snapshot containing exact previous `current.json` and `previous.json` contents and their SHA-256 values. Files are copied to a unique staging directory and rehashed. Only then is staging moved to `packages/<package>/versions/<version>` and the package-local pointer updated atomically.
|
Before changing package pointers, Launcher Next writes an activation snapshot containing exact previous `current.json` and `previous.json` contents and their SHA-256 values. Files are copied to a unique staging directory and rehashed. Only then is staging moved to `packages/<package>/versions/<version>` and the package-local pointer updated atomically.
|
||||||
|
|
||||||
Rollback restores the exact pointers, deletes the pilot version and startup script, and removes incomplete staging. Autodesk assemblies, PDBs, executables, test assemblies and legacy files are rejected from the pilot package.
|
Rollback restores the exact pointers, deletes the pilot version and startup script, and removes incomplete staging. Autodesk assemblies, PDBs, executables, test assemblies and legacy files are rejected from the pilot package.
|
||||||
|
|
||||||
|
## Last-known-good resolution
|
||||||
|
|
||||||
|
Each activated version retains its validated package manifest alongside its payload.
|
||||||
|
`LocalPackageStore.ResolveAsync` revalidates the manifest, package identity/product
|
||||||
|
compatibility, entry payload and every payload SHA-256 before returning a local
|
||||||
|
version. It returns current first. Only when current is invalid and the caller
|
||||||
|
explicitly permits offline fallback can it return previous. It never reads or
|
||||||
|
executes a package from DFS, and a failed or corrupted update never becomes
|
||||||
|
last-known-good.
|
||||||
|
|||||||
@@ -21,6 +21,31 @@ public sealed class FoundationTests
|
|||||||
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 7 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes)); var store = new LocalPackageStore(root, new Sha256FileHasher()); var result = await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)), source, CancellationToken.None); Assert.Equal("2026.1.0", result.CurrentVersion); Assert.True(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", result.CurrentVersion))); Directory.Delete(root, true); }
|
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 7 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes)); var store = new LocalPackageStore(root, new Sha256FileHasher()); var result = await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)), source, CancellationToken.None); Assert.Equal("2026.1.0", result.CurrentVersion); Assert.True(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", result.CurrentVersion))); Directory.Delete(root, true); }
|
||||||
[Fact] public async Task Synchronizer_HashMismatch_IsRejected()
|
[Fact] public async Task Synchronizer_HashMismatch_IsRejected()
|
||||||
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "x"); var store = new LocalPackageStore(root, new Sha256FileHasher()); await Assert.ThrowsAsync<InvalidOperationException>(() => store.SynchronizeAsync(Manifest(Package("PRecAnnot.Plant3D2026.dll")), source, CancellationToken.None)); Assert.False(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.1.0"))); Directory.Delete(root, true); }
|
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "x"); var store = new LocalPackageStore(root, new Sha256FileHasher()); await Assert.ThrowsAsync<InvalidOperationException>(() => store.SynchronizeAsync(Manifest(Package("PRecAnnot.Plant3D2026.dll")), source, CancellationToken.None)); Assert.False(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.1.0"))); Directory.Delete(root, true); }
|
||||||
|
[Fact] public async Task LastKnownGood_RejectsCorruptCurrent_AndUsesVerifiedPreviousOffline()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher());
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "version-one"); var firstHash = Convert.ToHexString(SHA256.HashData(await System.IO.File.ReadAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll")))); await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 11, firstHash)), source, CancellationToken.None);
|
||||||
|
await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "version-two"); var secondHash = Convert.ToHexString(SHA256.HashData(await System.IO.File.ReadAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll")))); var second = Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 11, secondHash)) with { Version = "2026.2.0" }; await store.SynchronizeAsync(second, source, CancellationToken.None);
|
||||||
|
await System.IO.File.WriteAllTextAsync(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.2.0", "PRecAnnot.Plant3D2026.dll"), "corrupt");
|
||||||
|
var resolved = await store.ResolveAsync("P_Rec_Annot", "Plant3D", 2026, "x64", true, CancellationToken.None);
|
||||||
|
Assert.NotNull(resolved); Assert.Equal("2026.1.0", resolved!.Version); Assert.True(resolved.IsOfflineFallback); Assert.Equal("PREVIOUS_VERIFIED_AFTER_CURRENT_REJECTED", resolved.Reason);
|
||||||
|
}
|
||||||
|
finally { Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
[Fact] public async Task LastKnownGood_DoesNotFallback_WhenOfflinePolicyIsDisabled()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes)); var store = new LocalPackageStore(root, new Sha256FileHasher());
|
||||||
|
try { await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)), source, CancellationToken.None); await store.SynchronizeAsync((Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)) with { Version = "2026.2.0" }), source, CancellationToken.None); System.IO.File.Delete(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.2.0", "PRecAnnot.Plant3D2026.dll")); Assert.Null(await store.ResolveAsync("P_Rec_Annot", "Plant3D", 2026, "x64", false, CancellationToken.None)); }
|
||||||
|
finally { Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
[Fact] public async Task Synchronizer_RejectsReservedStoredManifestPayload()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes));
|
||||||
|
try { var store = new LocalPackageStore(root, new Sha256FileHasher()); var manifest = Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash), new PackageFile("package.manifest.json", 1, hash)); var error = await Assert.ThrowsAsync<InvalidOperationException>(() => store.SynchronizeAsync(manifest, source, CancellationToken.None)); Assert.Equal("MANIFEST_RESERVED_FILE", error.Message); }
|
||||||
|
finally { Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
[Fact] public void StartupScript_ContainsOneNetload_AndEscapesSpaces()
|
[Fact] public void StartupScript_ContainsOneNetload_AndEscapesSpaces()
|
||||||
{ var root = Path.Combine(Path.GetTempPath(), "space root", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); System.IO.File.WriteAllText(Path.Combine(root, "PRecAnnot.Plant3D2026.dll"), ""); var s = new StartupScriptBuilder().BuildNetloadScript(root, "PRecAnnot.Plant3D2026.dll"); Assert.Single(System.Text.RegularExpressions.Regex.Matches(s, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); Assert.Contains("space root", s); Directory.Delete(Path.GetDirectoryName(root)!, true); }
|
{ var root = Path.Combine(Path.GetTempPath(), "space root", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); System.IO.File.WriteAllText(Path.Combine(root, "PRecAnnot.Plant3D2026.dll"), ""); var s = new StartupScriptBuilder().BuildNetloadScript(root, "PRecAnnot.Plant3D2026.dll"); Assert.Single(System.Text.RegularExpressions.Regex.Matches(s, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); Assert.Contains("space root", s); Directory.Delete(Path.GetDirectoryName(root)!, true); }
|
||||||
[Fact] public void ProcessLauncher_DryRun_DoesNotStartProcess()
|
[Fact] public void ProcessLauncher_DryRun_DoesNotStartProcess()
|
||||||
|
|||||||
Reference in New Issue
Block a user