From aae0a2db3e1ea83edc418e38d34ddb49684c5196 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 9 Jul 2026 15:40:14 -0400 Subject: [PATCH] fix(transport): reject bundle imports that would persist an inheritance/composition cycle Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj --- .../Import/BundleImporter.cs | 68 ++++++++++ .../Import/InheritanceImportTests.cs | 120 ++++++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 882198a0..ddd9e89c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -18,6 +18,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; +using ZB.MOM.WW.ScadaBridge.TemplateEngine; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; using ZB.MOM.WW.ScadaBridge.Transport.Encryption; using ZB.MOM.WW.ScadaBridge.Transport.Serialization; @@ -1044,6 +1045,19 @@ public sealed class BundleImporter : IBundleImporter await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false); await ResolveInheritanceEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false); + // ---- Import-time acyclicity guard over the merged template graph ---- + // The two rewire passes above have STAGED (not yet saved) the bundle's + // inheritance (ParentTemplateId) and composition edges onto the tracked + // target templates. A bundle can still close a loop through a pre-existing + // target template — e.g. Overwrite a base so it now inherits from (or + // composes) one of its own descendants. The design-time save-gate enforces + // acyclicity on individual edits, but an import merges many edges at once, + // so re-check the whole merged graph here. On a cycle we throw and the + // catch below rolls the whole import back (ChangeTracker.Clear on the + // in-memory provider) BEFORE any of it is persisted; deliberately no + // SaveChanges precedes this check so nothing survives that rollback. + EnsureNoTemplateGraphCycles(); + // ---- Site/instance-scoped apply: instances LAST ---- // The instance pass needs every FK target materialised first: // • template id — resolved by name against the just-flushed central @@ -2178,6 +2192,60 @@ public sealed class BundleImporter : IBundleImporter } } + /// + /// Post-rewire acyclicity guard: reads the fully-merged template graph off the + /// change tracker (staged, not-yet-saved edges included) and rejects the import + /// if the bundle's inheritance or composition edges would persist a cycle — + /// e.g. an Overwrite that makes a base inherit from, or compose, its own + /// descendant. Delegates to the authoritative TemplateEngine + /// . Throws + /// on the first cycle found; the caller's catch rolls the whole import back. + /// + /// + /// The template list is taken from the change tracker rather than a fresh query + /// so it reflects the staged edges without a premature flush. It is complete for + /// cycle detection: any edge that references a pre-existing target template forces + /// a full GetAllTemplatesAsync load in the rewire passes (tracking every + /// template), and any all-imported cycle consists solely of tracked (Local) + /// templates — so every node of any candidate cycle is present. + /// + private void EnsureNoTemplateGraphCycles() + { + var templates = _dbContext.ChangeTracker.Entries