feat: add guarded controlled entity selection transport
This commit is contained in:
@@ -28,13 +28,26 @@ public sealed class StartupScriptBuilder : IStartupScriptBuilder
|
|||||||
var diagnostic = paths.Diagnostic
|
var diagnostic = paths.Diagnostic
|
||||||
? "_.CMDECHO\n1\n_.NOMUTT\n0\n_.LOGFILEMODE\n1\n"
|
? "_.CMDECHO\n1\n_.NOMUTT\n0\n_.LOGFILEMODE\n1\n"
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
var commands = paths.Apply
|
var commands = paths.Selection is null ? BuildDefaultCommands(paths) : BuildSelectedEntityCommands(paths);
|
||||||
|
return dialogFreeOpen + "_.OPEN\n" + quote(fixture) + "\n" + diagnostic + BuildNetloadScript(paths.ValidatedPackageRoot, paths.EntryAssembly) +
|
||||||
|
"PHP_REC_AUTOMATION_PREPARE\n" + commands + "PHP_REC_AUTOMATION_FINALIZE\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildDefaultCommands(ResolvedAutomationScriptPaths paths) => paths.Apply
|
||||||
? "_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK\n"
|
? "_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK\n"
|
||||||
: paths.Diagnostic
|
: paths.Diagnostic
|
||||||
? "PHP_REC_AUTOMATION_BEFORE_PXP_1\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_1\nPHP_REC_AUTOMATION_BEFORE_PXP_2\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_2\n"
|
? "PHP_REC_AUTOMATION_BEFORE_PXP_1\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_1\nPHP_REC_AUTOMATION_BEFORE_PXP_2\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_2\n"
|
||||||
: "PXP-OA\nPXP-OA\n";
|
: "PXP-OA\nPXP-OA\n";
|
||||||
return dialogFreeOpen + "_.OPEN\n" + quote(fixture) + "\n" + diagnostic + BuildNetloadScript(paths.ValidatedPackageRoot, paths.EntryAssembly) +
|
|
||||||
"PHP_REC_AUTOMATION_PREPARE\n" + commands + "PHP_REC_AUTOMATION_FINALIZE\n";
|
private static string BuildSelectedEntityCommands(ResolvedAutomationScriptPaths paths)
|
||||||
|
{
|
||||||
|
var selection = paths.Selection!;
|
||||||
|
selection.Validate();
|
||||||
|
var invoke = "(setq P_REC_ANNOT_SELECTED_ENTITY (handent \"" + selection.ObjectHandle.ToUpperInvariant() + "\"))\n" +
|
||||||
|
"(if (and P_REC_ANNOT_SELECTED_ENTITY (= (cdr (assoc 0 (entget P_REC_ANNOT_SELECTED_ENTITY))) \"" + selection.ExpectedDxfName.ToUpperInvariant() + "\")) " +
|
||||||
|
"(command-s \"" + selection.CommandName.ToUpperInvariant() + "\" P_REC_ANNOT_SELECTED_ENTITY) " +
|
||||||
|
"(progn (prompt \"\\nP_REC_ANNOT_SELECTION_REJECTED\") (quit)))\n";
|
||||||
|
return paths.Apply ? "_.UNDO\n_MARK\n" + invoke + invoke + "_.UNDO\n_BACK\n" : invoke + invoke;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
|
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public sealed record CachedPackageResolution(string PackageId, string Version, s
|
|||||||
public interface ILastKnownGoodPackageResolver { Task<CachedPackageResolution?> ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken); }
|
public interface ILastKnownGoodPackageResolver { Task<CachedPackageResolution?> ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken); }
|
||||||
public interface IPackageRequirementCacheBinder { Task BindAsync(PackageRequirement requirement, string version, CancellationToken cancellationToken); }
|
public interface IPackageRequirementCacheBinder { Task BindAsync(PackageRequirement requirement, string version, 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, ControlledEntitySelection? Selection = null);
|
||||||
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); }
|
||||||
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
|
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
|
||||||
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
|
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ public sealed record PilotLocalConfiguration
|
|||||||
public bool AutomationDiagnostic { get; init; }
|
public bool AutomationDiagnostic { get; init; }
|
||||||
public string? AutomationConfigurationPath { get; init; }
|
public string? AutomationConfigurationPath { get; init; }
|
||||||
public string? FixtureMarkerPath { get; init; }
|
public string? FixtureMarkerPath { get; init; }
|
||||||
|
// Controlled automation may reproduce an actual Editor.GetEntity boundary
|
||||||
|
// only with this immutable, explicitly typed fixture selection. It is never
|
||||||
|
// populated for normal production launches.
|
||||||
|
public ControlledEntitySelection? AutomationSelection { get; init; }
|
||||||
// Test-only: permits a pilot process to be terminated after its plugin has
|
// Test-only: permits a pilot process to be terminated after its plugin has
|
||||||
// persisted completed automation evidence. It never represents graceful exit.
|
// persisted completed automation evidence. It never represents graceful exit.
|
||||||
public bool ForceAfterEvidence { get; init; }
|
public bool ForceAfterEvidence { get; init; }
|
||||||
@@ -34,6 +38,16 @@ public sealed record PilotLocalConfiguration
|
|||||||
public IReadOnlyDictionary<string, string?> PathVariables { get; init; } = new Dictionary<string, string?>();
|
public IReadOnlyDictionary<string, string?> PathVariables { get; init; } = new Dictionary<string, string?>();
|
||||||
public static PilotLocalConfiguration SafeDefault { get; } = new();
|
public static PilotLocalConfiguration SafeDefault { get; } = new();
|
||||||
}
|
}
|
||||||
|
public sealed record ControlledEntitySelection(string CommandName, string ObjectHandle, string ExpectedDxfName)
|
||||||
|
{
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(CommandName) || !CommandName.All(value => char.IsAsciiLetterOrDigit(value) || value is '-' or '_') ||
|
||||||
|
string.IsNullOrWhiteSpace(ObjectHandle) || ObjectHandle.Length > 32 || !ObjectHandle.All(Uri.IsHexDigit) ||
|
||||||
|
string.IsNullOrWhiteSpace(ExpectedDxfName) || !ExpectedDxfName.All(value => char.IsAsciiLetterOrDigit(value) || value == '_'))
|
||||||
|
throw new InvalidOperationException("AUTOMATION_ENTITY_SELECTION_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe }
|
public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe }
|
||||||
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
|
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
|
||||||
{ public bool IsValid => Errors.Count == 0; }
|
{ public bool IsValid => Errors.Count == 0; }
|
||||||
@@ -83,6 +97,7 @@ public sealed class NextPilotSafetyGate : INextPilotSafetyGate
|
|||||||
if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && (!Path.IsPathFullyQualified(c.Configuration.ApplyPermitPath ?? string.Empty) || (c.Configuration.ApplyPermitPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("APPLY_PERMIT_PATH_REQUIRED");
|
if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && (!Path.IsPathFullyQualified(c.Configuration.ApplyPermitPath ?? string.Empty) || (c.Configuration.ApplyPermitPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("APPLY_PERMIT_PATH_REQUIRED");
|
||||||
var automation = c.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
|
var automation = c.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
|
||||||
if (automation && (!string.Equals(c.Configuration.AutomationMode, "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.AutomationConfigurationPath ?? string.Empty) || (c.Configuration.AutomationConfigurationPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.FixtureMarkerPath ?? string.Empty) || (c.Configuration.FixtureMarkerPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || c.Configuration.ProductionDeploy)) v.Add("AUTOMATION_CONFIGURATION_INVALID");
|
if (automation && (!string.Equals(c.Configuration.AutomationMode, "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.AutomationConfigurationPath ?? string.Empty) || (c.Configuration.AutomationConfigurationPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.FixtureMarkerPath ?? string.Empty) || (c.Configuration.FixtureMarkerPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || c.Configuration.ProductionDeploy)) v.Add("AUTOMATION_CONFIGURATION_INVALID");
|
||||||
|
try { c.Configuration.AutomationSelection?.Validate(); } catch (InvalidOperationException) { v.Add("AUTOMATION_ENTITY_SELECTION_INVALID"); }
|
||||||
if (!automation && c.Configuration.AutomationMode is not null) v.Add("AUTOMATION_MODE_MISMATCH");
|
if (!automation && c.Configuration.AutomationMode is not null) v.Add("AUTOMATION_MODE_MISMATCH");
|
||||||
if (c.Configuration.ForceAfterEvidence && !automation) v.Add("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED");
|
if (c.Configuration.ForceAfterEvidence && !automation) v.Add("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED");
|
||||||
if (c.Product is null) v.Add("PRODUCT_NOT_FOUND"); else if (c.Product.ProductVersion != "2026" || c.Product.Architecture != "x64") v.Add("PRODUCT_NOT_PLANT3D_2026");
|
if (c.Product is null) v.Add("PRODUCT_NOT_FOUND"); else if (c.Product.ProductVersion != "2026" || c.Product.Architecture != "x64") v.Add("PRODUCT_NOT_PLANT3D_2026");
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ public sealed class PilotExecutionService(
|
|||||||
scriptPath = paths.Require(LauncherPathVariableNames.AutomationScriptPath);
|
scriptPath = paths.Require(LauncherPathVariableNames.AutomationScriptPath);
|
||||||
if (!Path.GetFullPath(scriptPath).StartsWith(Path.GetFullPath(paths.Require(LauncherPathVariableNames.LocalSessionRoot)).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AUTOMATION_SCRIPT_OUTSIDE_SESSION_ROOT");
|
if (!Path.GetFullPath(scriptPath).StartsWith(Path.GetFullPath(paths.Require(LauncherPathVariableNames.LocalSessionRoot)).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AUTOMATION_SCRIPT_OUTSIDE_SESSION_ROOT");
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
|
||||||
var script = scriptBuilder.BuildAutomationScript(new(versionDirectory, request.Preparation.Manifest.EntryAssembly, paths.Require(LauncherPathVariableNames.FixtureOrthoDwg), paths.Require(LauncherPathVariableNames.FixtureModelDwg), scriptPath, request.Preparation.Configuration.AutomationDiagnostic, request.Preparation.Configuration.LaunchMode == AcadLaunchMode.NextPilotApply));
|
var script = scriptBuilder.BuildAutomationScript(new(versionDirectory, request.Preparation.Manifest.EntryAssembly, paths.Require(LauncherPathVariableNames.FixtureOrthoDwg), paths.Require(LauncherPathVariableNames.FixtureModelDwg), scriptPath, request.Preparation.Configuration.AutomationDiagnostic, request.Preparation.Configuration.LaunchMode == AcadLaunchMode.NextPilotApply, request.Preparation.Configuration.AutomationSelection));
|
||||||
await File.WriteAllTextAsync(scriptPath, script, cancellationToken);
|
await File.WriteAllTextAsync(scriptPath, script, cancellationToken);
|
||||||
}
|
}
|
||||||
var arguments = new AcadArgumentBuilder().Build(request.Preparation.Configuration.LaunchMode, automation ? scriptPath : null);
|
var arguments = new AcadArgumentBuilder().Build(request.Preparation.Configuration.LaunchMode, automation ? scriptPath : null);
|
||||||
|
|||||||
@@ -87,6 +87,24 @@ public sealed class PathAndLaunchTests
|
|||||||
try { var script = new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "apply.scr"), false, true)); Assert.Contains("_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK", script); }
|
try { var script = new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "apply.scr"), false, true)); Assert.Contains("_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK", script); }
|
||||||
finally { Directory.Delete(root, true); }
|
finally { Directory.Delete(root, true); }
|
||||||
}
|
}
|
||||||
|
[Fact]
|
||||||
|
public void SelectedEntityScript_UsesExactHandleAndDxfTypeWithoutCoordinates()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var entry = "P.dll"; var fixture = Path.Combine(root, "fixture.dwg"); var model = Path.Combine(root, "model.dwg"); File.WriteAllText(Path.Combine(root, entry), ""); File.WriteAllText(fixture, ""); File.WriteAllText(model, "");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var script = new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "selected.scr"), false, true, new("PXP-AAV", "1a2b", "VIEWPORT")));
|
||||||
|
Assert.Contains("(handent \"1A2B\")", script); Assert.Contains("(command-s \"PXP-AAV\" P_REC_ANNOT_SELECTED_ENTITY)", script); Assert.Contains("P_REC_ANNOT_SELECTION_REJECTED", script); Assert.DoesNotContain("_.ZOOM", script); Assert.DoesNotContain("_.POINT", script);
|
||||||
|
}
|
||||||
|
finally { Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
[Fact]
|
||||||
|
public void SelectedEntityScript_RejectsUnsafeSelectionTokens()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var entry = "P.dll"; var fixture = Path.Combine(root, "fixture.dwg"); var model = Path.Combine(root, "model.dwg"); File.WriteAllText(Path.Combine(root, entry), ""); File.WriteAllText(fixture, ""); File.WriteAllText(model, "");
|
||||||
|
try { Assert.Throws<InvalidOperationException>(() => new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "selected.scr"), false, false, new("PXP-AAV;QUIT", "1A2B", "VIEWPORT")))); }
|
||||||
|
finally { Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
[Fact] public void DiagnosticScript_RecordsExactBytes() { var text = "PXP-OA\n"; Assert.Equal(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))), Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text)))); }
|
[Fact] public void DiagnosticScript_RecordsExactBytes() { var text = "PXP-OA\n"; Assert.Equal(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))), Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text)))); }
|
||||||
[Fact] public void DiagnosticScript_HasTrailingNewline() => Assert.EndsWith("\n", "PHP_REC_AUTOMATION_FINALIZE\n");
|
[Fact] public void DiagnosticScript_HasTrailingNewline() => Assert.EndsWith("\n", "PHP_REC_AUTOMATION_FINALIZE\n");
|
||||||
[Fact] public void DiagnosticScript_RecordsEncoding() => Assert.Equal("utf-8", System.Text.Encoding.UTF8.WebName);
|
[Fact] public void DiagnosticScript_RecordsEncoding() => Assert.Equal("utf-8", System.Text.Encoding.UTF8.WebName);
|
||||||
|
|||||||
Reference in New Issue
Block a user