From d0f858c77df41f418fadcb9d0578744e596b0ce1 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Fri, 31 Jul 2026 15:45:33 +0200 Subject: [PATCH] Replaced payload hash with document hash for better integrity --- .../VisualBriefingArtifactParts.cs | 4 +- .../VisualBriefingArtifactService.Assembly.cs | 111 +++--- .../VisualBriefingArtifactService.Parsing.cs | 354 ++++++++---------- .../VisualBriefingArtifactService.cs | 15 +- .../VisualBriefingAssistant.razor.Versions.cs | 25 +- ...sualBriefingBuildOrchestrator.Recompile.cs | 8 +- .../VisualBriefingBuildOrchestrator.cs | 10 +- .../VisualBriefingExportManifest.cs | 4 +- .../VisualBriefingPreviewEndpoint.cs | 2 +- .../VisualBriefingStore.Projects.cs | 12 +- .../VisualBriefingStore.Recovery.cs | 21 +- .../VisualBriefingStore.Versions.cs | 68 ++-- .../VisualBriefing/VisualBriefingVersion.cs | 22 +- 13 files changed, 331 insertions(+), 325 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactParts.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactParts.cs index 72bd5474..3ea31f26 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactParts.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactParts.cs @@ -11,7 +11,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// The safe presentation stylesheet. /// The embedded AI Studio runtime. /// The optional embedded Apache ECharts runtime. -/// The protected payload hash. +/// The SHA-256 hash of the complete standalone document. public sealed record VisualBriefingArtifactParts( VisualBriefingExportManifest ExportManifest, JsonElement Data, @@ -19,4 +19,4 @@ public sealed record VisualBriefingArtifactParts( string Css, string RuntimeScript, string? EChartsScript, - string PayloadHash); \ No newline at end of file + string DocumentHash); \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Assembly.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Assembly.cs index 17ad23de..5b01c5de 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Assembly.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Assembly.cs @@ -25,12 +25,7 @@ public sealed partial class VisualBriefingArtifactService /// An existing chart runtime to reuse, keeping a revision reproducible. /// The cancellation token. /// The complete standalone HTML document. - public Task BuildAsync( - VisualBriefingManifest manifest, - VisualBriefingRevisionRequest request, - string? lockedRuntimeScript = null, - string? lockedEChartsScript = null, - CancellationToken token = default) + public Task BuildAsync(VisualBriefingManifest manifest, VisualBriefingRevisionRequest request, string? lockedRuntimeScript = null, string? lockedEChartsScript = null, CancellationToken token = default) { token.ThrowIfCancellationRequested(); var data = AddProtectedArtifactData(manifest, request); @@ -51,49 +46,59 @@ public sealed partial class VisualBriefingArtifactService if (usesCharts && string.IsNullOrWhiteSpace(echarts)) throw new InvalidOperationException("Apache ECharts 6.1.0 common is not available in this AI Studio build."); - var payloadHash = ComputePayloadHash(dataJson, template, css, runtime, echarts); var exportMetadata = request.ExportMetadataSource; var htmlLanguage = GetHtmlLanguage( exportMetadata?.TargetLanguage ?? manifest.Settings.TargetLanguage, exportMetadata?.CustomTargetLanguage ?? manifest.Settings.CustomTargetLanguage); var briefingName = exportMetadata?.Name ?? manifest.Name; - var exportManifest = CreateExportManifest( - manifest, - request, - payloadHash, - this.AIStudioVersion, - runtimeAIStudioVersion); - - var encodedManifest = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(exportManifest, JSON_OPTIONS))); - var csp = GetContentSecurityPolicy(new(exportManifest, data, template, css, runtime, echarts, payloadHash)); + var exportManifest = CreateExportManifest(manifest, request, DOCUMENT_HASH_PLACEHOLDER, this.AIStudioVersion, runtimeAIStudioVersion); - return Task.FromResult($""" - - - - - - - - {HtmlEncode(briefingName)} - - - - - -
{template}
-
- {STATIC_FOOTER_TEMPLATE} -
- {BuildScriptTag(echarts, "mwai-echarts-runtime")} - - - - """); + var parts = new VisualBriefingArtifactParts(exportManifest, data, template, css, runtime, echarts, DOCUMENT_HASH_PLACEHOLDER); + var csp = GetContentSecurityPolicy(parts); + var placeholderDocument = AssembleDocument(exportManifest, htmlLanguage, briefingName, dataJson, template, css, runtime, echarts, csp); + + exportManifest.DocumentHash = VisualBriefingHashing.Compute(placeholderDocument); + return Task.FromResult(AssembleDocument(exportManifest, htmlLanguage, briefingName, dataJson, template, css, runtime, echarts, csp)); } + /// + /// Assembles the deterministic document around a supplied artifact header. + /// + private static string AssembleDocument(VisualBriefingExportManifest exportManifest, string htmlLanguage, string briefingName, string dataJson, string template, string css, string runtime, string? echarts, string csp) + { + var encodedHeader = EncodeHeader(exportManifest); + return $""" + + + + + + + + + {HtmlEncode(briefingName)} + + + + + +
{template}
+
+ {STATIC_FOOTER_TEMPLATE} +
+ {BuildScriptTag(echarts, "mwai-echarts-runtime")} + + + + """; + } + + /// + /// Encodes the stable JSON artifact header for embedding in an HTML comment. + /// + private static string EncodeHeader(VisualBriefingExportManifest exportManifest) => Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(exportManifest, JSON_OPTIONS))); + /// /// Defines RuntimeAIVersionRegex for the visual briefing feature. /// @@ -109,7 +114,7 @@ public sealed partial class VisualBriefingArtifactService /// Defines the protected, app-owned static footer template. /// private const string STATIC_FOOTER_TEMPLATE = """ - + Created with MindWork AI Studio v. @@ -149,6 +154,15 @@ public sealed partial class VisualBriefingArtifactService visibility: visible !important; opacity: 1 !important; } + #mwai-static-footer a { + display: inline !important; + visibility: visible !important; + opacity: 1 !important; + color: inherit !important; + font: inherit !important; + text-decoration: underline !important; + text-underline-offset: .15em !important; + } @media print { html, body { background: #fffefa !important; @@ -170,12 +184,6 @@ public sealed partial class VisualBriefingArtifactService return $"default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src {ScriptCspHash(parts.RuntimeScript)}{echartsHash}; font-src 'none'; media-src 'none'; frame-src 'none'; connect-src 'none'; form-action 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'self'"; } - /// - /// Defines ComputePayloadHash for the visual briefing feature. - /// - private static string ComputePayloadHash(string dataJson, string template, string css, string runtime, string? echarts) => - VisualBriefingHashing.ComputeSections(dataJson, template, css, runtime, echarts); - /// /// Defines ScriptCspHash for the visual briefing feature. /// @@ -238,12 +246,7 @@ public sealed partial class VisualBriefingArtifactService /// /// Defines CreateExportManifest for the visual briefing feature. /// - private static VisualBriefingExportManifest CreateExportManifest( - VisualBriefingManifest manifest, - VisualBriefingRevisionRequest request, - string payloadHash, - string aiStudioVersion, - string runtimeAIStudioVersion) + private static VisualBriefingExportManifest CreateExportManifest(VisualBriefingManifest manifest, VisualBriefingRevisionRequest request, string documentHash, string aiStudioVersion, string runtimeAIStudioVersion) { var source = request.ExportMetadataSource; return new() @@ -265,7 +268,7 @@ public sealed partial class VisualBriefingArtifactService CustomProtectionLevel = source?.CustomProtectionLevel ?? manifest.Settings.CustomProtectionLevel, AIStudioVersion = aiStudioVersion, RuntimeAIStudioVersion = runtimeAIStudioVersion, - PayloadHash = payloadHash, + DocumentHash = documentHash, }; } diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Parsing.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Parsing.cs index 001f7a54..348faaad 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Parsing.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Parsing.cs @@ -9,195 +9,147 @@ namespace AIStudio.Assistants.VisualBriefing; public sealed partial class VisualBriefingArtifactService { /// - /// Defines ManifestRegex for the visual briefing feature. + /// Matches the version-independent artifact header at the start of standalone HTML. /// - private static readonly Regex MANIFEST_REGEX = ManifestRegex(); + private static readonly Regex HEADER_REGEX = HeaderRegex(); /// - /// Defines ManifestRegex for the visual briefing feature. + /// Matches the version-independent artifact header at the start of standalone HTML. /// - [GeneratedRegex("", RegexOptions.CultureInvariant)] - private static partial Regex ManifestRegex(); + [GeneratedRegex(@"\A\n\n", RegexOptions.CultureInvariant)] + private static partial Regex HeaderRegex(); /// - /// Defines StyleRegex for the visual briefing feature. + /// Matches the generated presentation stylesheet. /// private static readonly Regex STYLE_REGEX = StyleRegex(); /// - /// Defines StyleRegex for the visual briefing feature. + /// Matches the generated presentation stylesheet. /// [GeneratedRegex("""(?[\s\S]*?)""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex StyleRegex(); /// - /// Defines RuntimeRegex for the visual briefing feature. + /// Matches the embedded declarative runtime. /// private static readonly Regex RUNTIME_REGEX = RuntimeRegex(); /// - /// Defines RuntimeRegex for the visual briefing feature. + /// Matches the embedded declarative runtime. /// [GeneratedRegex("""(?[\s\S]*?)""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex RuntimeRegex(); /// - /// Defines EChartsRegex for the visual briefing feature. + /// Matches the optional embedded chart runtime. /// private static readonly Regex ECHARTS_REGEX = EChartsRegex(); /// - /// Defines EChartsRegex for the visual briefing feature. + /// Matches the optional embedded chart runtime. /// [GeneratedRegex("""(?[\s\S]*?)""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex EChartsRegex(); /// - /// Parses a standalone artifact using the current runtime contract. + /// Reads an intact standalone artifact without applying current compiler or runtime rules. /// - /// The complete standalone HTML document. - /// The validated artifact parts. - /// The user-safe validation issue. - /// Whether the artifact is valid for the current runtime. - public static bool TryParse(string html, out VisualBriefingArtifactParts parts, out string issue) => TryParse(html, allowOutdatedRuntime: false, out parts, out issue); - - /// - /// Parses a locally stored parent artifact for recompilation while allowing a previous runtime - /// bundle that will be discarded before the new revision is assembled. - /// - /// The complete standalone HTML document. - /// The validated artifact parts. - /// The user-safe validation issue. - /// Whether the artifact is structurally valid for recompilation. - internal static bool TryParseForRecompile(string html, out VisualBriefingArtifactParts parts, out string issue) => TryParse(html, allowOutdatedRuntime: true, out parts, out issue); - - /// - /// Parses and validates a standalone artifact under the selected runtime policy. - /// - /// The complete standalone HTML document. - /// Whether a previous runtime bundle may be read but never reused. - /// The validated artifact parts. - /// The user-safe validation issue. - /// Whether the artifact passed all applicable checks. - private static bool TryParse(string html, bool allowOutdatedRuntime, out VisualBriefingArtifactParts parts, out string issue) + public static bool TryParse(string html, out VisualBriefingArtifactParts parts, out string issue) { parts = null!; issue = string.Empty; - + if (string.IsNullOrWhiteSpace(html)) { issue = "The briefing file is empty."; return false; } - if (!html.StartsWith("\n", StringComparison.Ordinal) || - !html.EndsWith("", StringComparison.Ordinal)) + if (!html.EndsWith("", StringComparison.Ordinal)) { - issue = "The briefing document wrapper is invalid or modified."; + issue = "The briefing document wrapper is invalid or incomplete."; return false; } - var manifestMatch = MANIFEST_REGEX.Match(html); - if (!manifestMatch.Success) + var headerMatch = HEADER_REGEX.Match(html); + if (!headerMatch.Success) { - issue = "The briefing compatibility manifest is missing."; + issue = "The briefing artifact header is missing or misplaced."; return false; } VisualBriefingExportManifest? exportManifest; try { - var json = Encoding.UTF8.GetString(Convert.FromBase64String(manifestMatch.Groups["value"].Value)); - using var manifestDocument = JsonDocument.Parse(json); - exportManifest = HasDuplicateProperties(manifestDocument.RootElement) + var json = Encoding.UTF8.GetString(Convert.FromBase64String(headerMatch.Groups["value"].Value)); + using var headerDocument = JsonDocument.Parse(json); + exportManifest = HasDuplicateProperties(headerDocument.RootElement) ? null - : manifestDocument.RootElement.Deserialize(JSON_OPTIONS); + : headerDocument.RootElement.Deserialize(JSON_OPTIONS); } catch (Exception exception) when (exception is FormatException or JsonException) { - issue = "The briefing compatibility manifest is invalid."; + issue = "The briefing artifact header is invalid."; return false; } - if (exportManifest is null || - exportManifest.ArtifactVersion != VisualBriefingVersions.ARTIFACT || - exportManifest.SchemaVersion != VisualBriefingVersions.SCHEMA || - exportManifest.RuntimeVersion <= 0 || - exportManifest.RuntimeVersion > VisualBriefingVersions.RUNTIME || - (!allowOutdatedRuntime && - exportManifest.RuntimeVersion != VisualBriefingVersions.RUNTIME) || - exportManifest.BriefingId == Guid.Empty || - exportManifest.RevisionId == Guid.Empty || - string.IsNullOrWhiteSpace(exportManifest.Name) || - string.IsNullOrWhiteSpace(exportManifest.AIStudioVersion) || - string.IsNullOrWhiteSpace(exportManifest.RuntimeAIStudioVersion) || - string.IsNullOrWhiteSpace(exportManifest.PayloadHash) || - exportManifest.PayloadHash.Length != 64 || - !exportManifest.PayloadHash.All(Uri.IsHexDigit) || - exportManifest.TargetLanguage is CommonLanguages.OTHER && - string.IsNullOrWhiteSpace(exportManifest.CustomTargetLanguage) || - exportManifest.ProtectionLevel is VisualBriefingProtectionLevel.OTHER && - string.IsNullOrWhiteSpace(exportManifest.CustomProtectionLevel)) + if (!ValidateHeader(exportManifest, out issue)) + return false; + + var documentHash = exportManifest!.DocumentHash; + exportManifest.DocumentHash = DOCUMENT_HASH_PLACEHOLDER; + var placeholderHeader = $"\n\n"; + exportManifest.DocumentHash = documentHash; + var placeholderDocument = placeholderHeader + html[headerMatch.Length..]; + var computedDocumentHash = VisualBriefingHashing.Compute(placeholderDocument); + if (!string.Equals(computedDocumentHash, documentHash, StringComparison.OrdinalIgnoreCase)) { - issue = "The briefing uses an unsupported or invalid artifact version."; + issue = "The briefing document hash does not match its contents."; return false; } var document = new HtmlDocument(); document.LoadHtml(html); - - var dataNode = FindElementById(document, DATA_ELEMENT_ID); - var rootNode = FindElementById(document, "mwai-briefing-root"); - var footerNode = FindElementById(document, "mwai-static-footer"); - var headNode = FindNode(document.DocumentNode, "//head"); - var bodyNode = FindNode(document.DocumentNode, "//body"); - var htmlNode = FindNode(document.DocumentNode, "//html"); + + var htmlNode = FindUniqueNode(document, "//html"); + var headNode = FindUniqueNode(document, "//head"); + var bodyNode = FindUniqueNode(document, "//body"); + var dataNode = FindUniqueElementById(document, DATA_ELEMENT_ID); + var rootNode = FindUniqueElementById(document, "mwai-briefing-root"); + var footerNode = FindUniqueElementById(document, "mwai-static-footer"); + var generatedStyleNode = FindUniqueElementById(document, "mwai-briefing-style"); + var protectedStyleNode = FindUniqueElementById(document, "mwai-protected-style"); + var runtimeNode = FindUniqueElementById(document, "mwai-briefing-runtime"); + var echartsNode = FindUniqueElementById(document, "mwai-echarts-runtime"); var styleMatch = STYLE_REGEX.Match(html); var runtimeMatch = RUNTIME_REGEX.Match(html); - - if (dataNode is null || rootNode is null || footerNode is null || headNode is null || bodyNode is null || - htmlNode is null || !styleMatch.Success || !runtimeMatch.Success) + var echartsMatch = ECHARTS_REGEX.Match(html); + + if (htmlNode is null || headNode is null || bodyNode is null || dataNode is null || rootNode is null || + footerNode is null || generatedStyleNode is null || protectedStyleNode is null || runtimeNode is null || + !styleMatch.Success || !runtimeMatch.Success || (echartsNode is not null) != echartsMatch.Success) { - issue = "The briefing structure is incomplete."; + issue = "The briefing envelope is incomplete or ambiguous."; return false; } - var headChildren = headNode.ChildNodes.Where(node => node.NodeType is HtmlNodeType.Element).ToArray(); - var metaNodes = headChildren.Where(node => node.Name.Equals("meta", StringComparison.OrdinalIgnoreCase)).ToArray(); - var styleNodes = headChildren.Where(node => node.Name.Equals("style", StringComparison.OrdinalIgnoreCase)).ToArray(); - var titleNodes = headChildren.Where(node => node.Name.Equals("title", StringComparison.OrdinalIgnoreCase)).ToArray(); - - if (headChildren.Length != 6 || - metaNodes.Length != 4 || - styleNodes.Length != 1 || - titleNodes.Length != 1 || - metaNodes.Count(node => string.Equals(node.GetAttributeValue("charset", string.Empty), "utf-8", StringComparison.OrdinalIgnoreCase)) != 1 || - metaNodes.Count(node => string.Equals(node.GetAttributeValue("name", string.Empty), "viewport", StringComparison.OrdinalIgnoreCase) && - string.Equals(node.GetAttributeValue("content", string.Empty), "width=device-width,initial-scale=1", StringComparison.Ordinal)) != 1 || - metaNodes.Count(node => string.Equals(node.GetAttributeValue("http-equiv", string.Empty), "Content-Security-Policy", StringComparison.OrdinalIgnoreCase)) != 1 || - metaNodes.Count(node => string.Equals(node.GetAttributeValue("name", string.Empty), "referrer", StringComparison.OrdinalIgnoreCase) && - string.Equals(node.GetAttributeValue("content", string.Empty), "no-referrer", StringComparison.OrdinalIgnoreCase)) != 1 || - metaNodes.Any(node => FindAttribute(node, "charset") is not null - ? !HasExactAttributes(node, "charset") - : !HasExactAttributes(node, FindAttribute(node, "http-equiv") is not null ? "http-equiv" : "name", "content")) || - styleNodes[0].Id != "mwai-briefing-style" || - !HasExactAttributes(styleNodes[0], "id") || - !HasExactAttributes(titleNodes[0]) || - !string.Equals(titleNodes[0].InnerText, exportManifest.Name, StringComparison.Ordinal) || - !HasExactAttributes(headNode) || - !HasExactAttributes(bodyNode) || - !HasExactAttributes(htmlNode, "lang") || - !string.Equals( - htmlNode.GetAttributeValue("lang", string.Empty), - GetHtmlLanguage(exportManifest.TargetLanguage, exportManifest.CustomTargetLanguage), - StringComparison.Ordinal)) + var scriptNodes = FindNodes(document.DocumentNode, "//script")?.ToArray() ?? []; + var styleNodes = FindNodes(document.DocumentNode, "//style")?.ToArray() ?? []; + if (scriptNodes.Any(node => node.Id is not DATA_ELEMENT_ID and not "mwai-echarts-runtime" and not "mwai-briefing-runtime") || + scriptNodes.Count(node => node.Id == DATA_ELEMENT_ID) != 1 || + scriptNodes.Count(node => node.Id == "mwai-briefing-runtime") != 1 || + scriptNodes.Count(node => node.Id == "mwai-echarts-runtime") > 1 || + styleNodes.Length != 2 || + styleNodes.Count(node => node.Id == "mwai-briefing-style") != 1 || + styleNodes.Count(node => node.Id == "mwai-protected-style") != 1 || + !string.Equals(dataNode.GetAttributeValue("type", string.Empty), "application/json", StringComparison.OrdinalIgnoreCase)) { - issue = "The briefing head or document structure was modified."; + issue = "The briefing contains unknown or duplicated executable resources."; return false; } var bodyChildren = FindNodes(document.DocumentNode, "//body/*")?.ToArray() ?? []; - var bodyComments = bodyNode.ChildNodes.Where(node => node.NodeType is HtmlNodeType.Comment).ToArray(); var allowedBodyIds = new HashSet(StringComparer.Ordinal) { DATA_ELEMENT_ID, @@ -206,34 +158,10 @@ public sealed partial class VisualBriefingArtifactService "mwai-echarts-runtime", "mwai-briefing-runtime", }; - if (bodyChildren.Any(node => !allowedBodyIds.Contains(node.Id)) || - bodyChildren.Select(node => node.Id).Distinct(StringComparer.Ordinal).Count() != bodyChildren.Length || - bodyComments.Length != 1 || - !string.Equals( - bodyComments[0].OuterHtml, - $"", - StringComparison.Ordinal) || - bodyNode.ChildNodes.Any(node => - node.NodeType is HtmlNodeType.Text && !string.IsNullOrWhiteSpace(node.InnerText)) || - CanonicalizeTemplate(footerNode.InnerHtml) != CanonicalizeTemplate(STATIC_FOOTER_TEMPLATE) || - !HasExactAttributes(dataNode, "id", "type") || - !HasExactAttributes(rootNode, "id") || - !HasExactAttributes(footerNode, "id", "class") || - !string.Equals(footerNode.GetAttributeValue("class", string.Empty), "mwai-footer", StringComparison.Ordinal)) + bodyChildren.Select(node => node.Id).Distinct(StringComparer.Ordinal).Count() != bodyChildren.Length) { - issue = "The briefing body or static footer structure was modified."; - return false; - } - - var scriptNodes = FindNodes(document.DocumentNode, "//script")?.ToArray() ?? []; - if (scriptNodes.Any(node => node.Id is not DATA_ELEMENT_ID and not "mwai-echarts-runtime" and not "mwai-briefing-runtime") || - scriptNodes.Count(node => node.Id == DATA_ELEMENT_ID) != 1 || - scriptNodes.Count(node => node.Id == "mwai-briefing-runtime") != 1 || - scriptNodes.Any(node => node.Id != DATA_ELEMENT_ID && !HasExactAttributes(node, "id")) || - !string.Equals(dataNode.GetAttributeValue("type", string.Empty), "application/json", StringComparison.OrdinalIgnoreCase)) - { - issue = "The briefing contains an unknown or duplicated script element."; + issue = "The briefing body contains elements outside the stable artifact envelope."; return false; } @@ -249,88 +177,110 @@ public sealed partial class VisualBriefingArtifactService return false; } - var protectedDataIssue = ValidateProtectedData(exportManifest, data); - if (!string.IsNullOrEmpty(protectedDataIssue)) - { - issue = protectedDataIssue; - return false; - } - var template = CanonicalizeTemplate(rootNode.InnerHtml); - var combinedCss = styleMatch.Groups["value"].Value.Trim(); - const string PROTECTED_CSS_SUFFIX = $"\n{PROTECTED_FOOTER_CSS}"; - - if (!combinedCss.EndsWith(PROTECTED_CSS_SUFFIX, StringComparison.Ordinal)) - { - issue = "The protected briefing footer stylesheet is missing or modified."; - return false; - } - - var css = combinedCss[..^PROTECTED_CSS_SUFFIX.Length].Trim(); + var css = styleMatch.Groups["value"].Value.Trim(); var runtime = runtimeMatch.Groups["value"].Value; - var echartsMatch = ECHARTS_REGEX.Match(html); var echarts = echartsMatch.Success ? echartsMatch.Groups["value"].Value : null; - - var usesCurrentRuntime = exportManifest.RuntimeVersion == VisualBriefingVersions.RUNTIME; - if (echarts is not null && usesCurrentRuntime && !string.Equals(echarts, ECHARTS_SCRIPT.Value, StringComparison.Ordinal)) + parts = new(exportManifest, data, template, css, runtime, echarts, documentHash); + + var cspNodes = FindNodes(document.DocumentNode, "//meta[@http-equiv='Content-Security-Policy']")?.ToArray() ?? []; + var actualCsp = cspNodes.Length == 1 + ? cspNodes[0].GetAttributeValue("content", string.Empty) + : string.Empty; + if (!string.Equals(actualCsp, GetContentSecurityPolicy(parts), StringComparison.Ordinal)) { - issue = "The briefing contains an unknown or modified ECharts runtime."; - return false; - } - - var validationIssue = ValidateGeneratedParts(null, data, template, css, !string.IsNullOrWhiteSpace(echarts)); - if (!string.IsNullOrEmpty(validationIssue)) - { - issue = validationIssue; - return false; - } - - if (usesCurrentRuntime && !string.Equals(runtime, BuildRuntimeScript(exportManifest.RuntimeAIStudioVersion), StringComparison.Ordinal)) - { - issue = "The briefing contains an unknown or modified AI Studio runtime."; + parts = null!; + issue = "The briefing Content Security Policy is missing or inconsistent with its embedded scripts."; return false; } - // A previous runtime is never executed or copied by the recompile path. Its payload, CSP, - // and locally persisted section hashes are still verified before semantic artifacts are read. - - var dataJson = JsonSerializer.Serialize(data, JSON_OPTIONS); - var payloadHash = ComputePayloadHash(dataJson, template, css, runtime, echarts); - - if (!string.Equals(payloadHash, exportManifest.PayloadHash, StringComparison.OrdinalIgnoreCase)) - { - issue = "The briefing payload hash does not match its manifest."; - return false; - } - - var expectedCsp = GetContentSecurityPolicy(new(exportManifest, data, template, css, runtime, echarts, payloadHash)); - var actualCsp = FindNode(document.DocumentNode, "//meta[@http-equiv='Content-Security-Policy']") - ?.GetAttributeValue("content", string.Empty); - - if (!string.Equals(actualCsp, expectedCsp, StringComparison.Ordinal)) - { - issue = "The briefing Content Security Policy is missing or modified."; - return false; - } - - parts = new(exportManifest, data, template, css, runtime, echarts, payloadHash); return true; } /// - /// Defines HasExactAttributes for the visual briefing feature. + /// Reads an intact artifact and additionally applies the current semantic compiler contract. /// - private static bool HasExactAttributes(HtmlNode node, params string[] expectedNames) + internal static bool TryParseForRecompile(string html, out VisualBriefingArtifactParts parts, out string issue) { - if (node.Attributes.Count != expectedNames.Length) + if (!TryParse(html, out parts, out issue)) return false; - return expectedNames.All(expectedName => - node.Attributes.Any(attribute => attribute.Name.Equals(expectedName, StringComparison.OrdinalIgnoreCase))); + if (parts.ExportManifest.SchemaVersion != VisualBriefingVersions.SCHEMA) + { + parts = null!; + issue = "The briefing data schema is not compatible with the current compiler."; + return false; + } + + issue = ValidateProtectedData(parts.ExportManifest, parts.Data); + if (!string.IsNullOrEmpty(issue)) + { + parts = null!; + return false; + } + + issue = ValidateGeneratedParts( + null, + parts.Data, + parts.TemplateHtml, + parts.Css, + !string.IsNullOrWhiteSpace(parts.EChartsScript)); + if (!string.IsNullOrEmpty(issue)) + { + parts = null!; + return false; + } + + return true; } /// - /// Defines ValidateProtectedData for the visual briefing feature. + /// Validates stable artifact-header fields without imposing current runtime or schema versions. + /// + private static bool ValidateHeader(VisualBriefingExportManifest? exportManifest, out string issue) + { + issue = string.Empty; + if (exportManifest is null || + exportManifest.ArtifactVersion != VisualBriefingVersions.ARTIFACT || + exportManifest.SchemaVersion <= 0 || + exportManifest.RuntimeVersion <= 0 || + exportManifest.BriefingId == Guid.Empty || + exportManifest.RevisionId == Guid.Empty || + string.IsNullOrWhiteSpace(exportManifest.Name) || + string.IsNullOrWhiteSpace(exportManifest.AIStudioVersion) || + string.IsNullOrWhiteSpace(exportManifest.RuntimeAIStudioVersion) || + exportManifest.DocumentHash.Length != 64 || + !exportManifest.DocumentHash.All(Uri.IsHexDigit) || + exportManifest.TargetLanguage is CommonLanguages.OTHER && string.IsNullOrWhiteSpace(exportManifest.CustomTargetLanguage) || + exportManifest.ProtectionLevel is VisualBriefingProtectionLevel.OTHER && string.IsNullOrWhiteSpace(exportManifest.CustomProtectionLevel)) + { + issue = "The briefing artifact header contains invalid or unsupported metadata."; + return false; + } + + return true; + } + + /// + /// Finds exactly one node for an XPath expression. + /// + private static HtmlNode? FindUniqueNode(HtmlDocument document, string xpath) + { + var nodes = FindNodes(document.DocumentNode, xpath)?.ToArray() ?? []; + return nodes.Length == 1 ? nodes[0] : null; + } + + /// + /// Finds exactly one element by ID. + /// + private static HtmlNode? FindUniqueElementById(HtmlDocument document, string id) + { + var nodes = FindNodes(document.DocumentNode, $"//*[@id='{id}']")?.ToArray() ?? []; + return nodes.Length == 1 ? nodes[0] : null; + } + + /// + /// Validates current protected data needed for recompilation. /// private static string ValidateProtectedData(VisualBriefingExportManifest exportManifest, JsonElement data) { @@ -362,7 +312,7 @@ public sealed partial class VisualBriefingArtifactService if (!protectedData.TryGetProperty("assetMetadata", out var assetMetadata) || assetMetadata.ValueKind is not JsonValueKind.Object) return "The protected visual asset metadata is missing."; - + var metadataProperties = assetMetadata.EnumerateObject().ToArray(); if (metadataProperties.Length != protectedAssetProperties.Length || metadataProperties.Any(property => diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.cs index 1354ee75..d6aa34ee 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.cs @@ -15,9 +15,14 @@ namespace AIStudio.Assistants.VisualBriefing; public sealed partial class VisualBriefingArtifactService { /// - /// Marks the Base64 compatibility manifest embedded in standalone HTML. + /// Marks the Base64 artifact header embedded at the start of standalone HTML. /// - private const string MANIFEST_MARKER = "MWAI_VISUAL_BRIEFING_MANIFEST:"; + private const string HEADER_MARKER = "MWAI_VISUAL_BRIEFING_HEADER:"; + + /// + /// Breaks the circular dependency while hashing a document that carries its own hash. + /// + private const string DOCUMENT_HASH_PLACEHOLDER = "0000000000000000000000000000000000000000000000000000000000000000"; /// /// Identifies the canonical JSON script element. @@ -90,12 +95,6 @@ public sealed partial class VisualBriefingArtifactService return NormalizeTemplate(FindElementById(document, "mwai-canonical-root")?.InnerHtml ?? string.Empty); } - /// - /// Defines GetHtmlLanguage for the visual briefing feature. - /// - private static string GetHtmlLanguage(VisualBriefingLocalSettings settings) => - GetHtmlLanguage(settings.TargetLanguage, settings.CustomTargetLanguage); - /// /// Defines GetHtmlLanguage for the visual briefing feature. /// diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Versions.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Versions.cs index 1075e895..2add24cc 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Versions.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Versions.cs @@ -21,6 +21,12 @@ public partial class VisualBriefingAssistant this.selectedBriefing?.Versions.FirstOrDefault(version => version.RevisionId == revisionId) is { + SchemaVersion: VisualBriefingVersions.SCHEMA, + IntermediateArtifactVersion: VisualBriefingVersions.INTERMEDIATE_ARTIFACT, + EvidenceContractVersion: VisualBriefingVersions.EVIDENCE_CONTRACT, + PlanContractVersion: VisualBriefingVersions.PLAN_CONTRACT, + ContentContractVersion: VisualBriefingVersions.CONTENT_CONTRACT, + DesignContractVersion: VisualBriefingVersions.DESIGN_CONTRACT, EvidenceArtifactId: not null, PlanArtifactId: not null, ContentArtifactId: not null, @@ -99,12 +105,6 @@ public partial class VisualBriefingAssistant if (sourcePath is null) return; - if (await this.Store.ReadVersionPartsAsync(this.selectedBriefing.BriefingId, this.selectedRevisionId) is null) - { - this.Snackbar.Add(T("The selected briefing version failed validation and cannot be exported."), Severity.Error); - return; - } - if (!await this.ConfirmLargeFileAsync(sourcePath, T("export"))) return; @@ -122,7 +122,14 @@ public partial class VisualBriefingAssistant return; } - await using var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true); + var verified = await this.Store.OpenIntegrityCheckedVersionAsync(this.selectedBriefing.BriefingId, this.selectedRevisionId); + if (verified is null) + { + this.Snackbar.Add(T("The selected briefing version failed its integrity check and cannot be exported."), Severity.Error); + return; + } + + await using var source = verified.Value.Stream; await using var destination = new FileStream(response.SaveFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 65_536, true); await source.CopyToAsync(destination); @@ -131,12 +138,12 @@ public partial class VisualBriefingAssistant this.Logger.LogInformation( new EventId((int)VisualBriefingLogEventId.EXPORT, VisualBriefingLogEventId.EXPORT.ToString()), - "Visual briefing version exported. OperationId={OperationId} BuildId={BuildId} BriefingId={BriefingId} RevisionId={RevisionId} PayloadHash={PayloadHash} Bytes={Bytes}", + "Visual briefing version exported. OperationId={OperationId} BuildId={BuildId} BriefingId={BriefingId} RevisionId={RevisionId} DocumentHash={DocumentHash} Bytes={Bytes}", exportedVersion.OperationId, exportedVersion.BuildId, this.selectedBriefing.BriefingId, exportedVersion.RevisionId, - exportedVersion.PayloadHash, + exportedVersion.DocumentHash, source.Length); this.Snackbar.Add(T("The visual briefing was exported."), Severity.Success); diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.Recompile.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.Recompile.cs index 13e77d3f..fb7b3b30 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.Recompile.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.Recompile.cs @@ -235,13 +235,13 @@ internal sealed partial class VisualBriefingBuildOrchestrator assemblyStage.Status = VisualBriefingBuildStageStatus.COMPLETED; assemblyStage.FinishedAtUtc = DateTimeOffset.UtcNow; - assemblyStage.OutputHash = revision.Version.PayloadHash; + assemblyStage.OutputHash = revision.Version.DocumentHash; commitStage.Status = VisualBriefingBuildStageStatus.COMPLETED; commitStage.StartedAtUtc = assemblyStage.FinishedAtUtc; commitStage.FinishedAtUtc = DateTimeOffset.UtcNow; - commitStage.InputFingerprint = revision.Version.PayloadHash; - commitStage.OutputHash = revision.Version.PayloadHash; + commitStage.InputFingerprint = revision.Version.DocumentHash; + commitStage.OutputHash = revision.Version.DocumentHash; build.CommittedRevisionId = revision.Version.RevisionId; build.Status = VisualBriefingBuildStatus.COMPLETED; @@ -250,7 +250,7 @@ internal sealed partial class VisualBriefingBuildOrchestrator await this.store.SaveBuildAsync(build, token); this.progressService.Publish(build); - diagnostics.ContentHashes["payload"] = revision.Version.PayloadHash; + diagnostics.ContentHashes["document"] = revision.Version.DocumentHash; diagnostics.FinishedAtUtc = DateTimeOffset.UtcNow; return new( diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.cs index 3a02cc20..b91bdfc7 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.cs @@ -394,13 +394,13 @@ internal sealed partial class VisualBriefingBuildOrchestrator assemblyStage.Status = VisualBriefingBuildStageStatus.COMPLETED; assemblyStage.FinishedAtUtc = DateTimeOffset.UtcNow; - assemblyStage.OutputHash = revision.Version.PayloadHash; + assemblyStage.OutputHash = revision.Version.DocumentHash; commitStage.Status = VisualBriefingBuildStageStatus.COMPLETED; commitStage.StartedAtUtc ??= assemblyStage.FinishedAtUtc; commitStage.FinishedAtUtc = DateTimeOffset.UtcNow; - commitStage.InputFingerprint = revision.Version.PayloadHash; - commitStage.OutputHash = revision.Version.PayloadHash; + commitStage.InputFingerprint = revision.Version.DocumentHash; + commitStage.OutputHash = revision.Version.DocumentHash; build.CommittedRevisionId = revision.Version.RevisionId; build.Status = VisualBriefingBuildStatus.COMPLETED; @@ -410,10 +410,10 @@ internal sealed partial class VisualBriefingBuildOrchestrator await this.store.SaveBuildAsync(build, token); this.progressService.Publish(build); - diagnostics.ContentHashes["payload"] = revision.Version.PayloadHash; + diagnostics.ContentHashes["document"] = revision.Version.DocumentHash; diagnostics.FinishedAtUtc = DateTimeOffset.UtcNow; - this.logger.LogInformation(Event(VisualBriefingLogEventId.REVISION_COMMITTED), "Visual briefing revision committed. OperationId={OperationId} BuildId={BuildId} VersionNumber={VersionNumber} RevisionId={RevisionId} PayloadHash={PayloadHash}", build.OperationId, build.BuildId, revision.Version.VersionNumber, revision.Version.RevisionId, revision.Version.PayloadHash); + this.logger.LogInformation(Event(VisualBriefingLogEventId.REVISION_COMMITTED), "Visual briefing revision committed. OperationId={OperationId} BuildId={BuildId} VersionNumber={VersionNumber} RevisionId={RevisionId} DocumentHash={DocumentHash}", build.OperationId, build.BuildId, revision.Version.VersionNumber, revision.Version.RevisionId, revision.Version.DocumentHash); return new(true, revision.Version, string.Empty, VisualBriefingFailureCode.NONE, diagnostics, false); } catch (OperationCanceledException) diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingExportManifest.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingExportManifest.cs index 238a4a0e..efa6fdb0 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingExportManifest.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingExportManifest.cs @@ -108,7 +108,7 @@ public sealed class VisualBriefingExportManifest public string RuntimeAIStudioVersion { get; set; } = string.Empty; /// - /// Defines PayloadHash for the visual briefing feature. + /// Gets or sets the SHA-256 hash of the complete standalone HTML document. /// - public string PayloadHash { get; set; } = string.Empty; + public string DocumentHash { get; set; } = string.Empty; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPreviewEndpoint.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPreviewEndpoint.cs index 1294c9fc..532e6f0d 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPreviewEndpoint.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPreviewEndpoint.cs @@ -45,7 +45,7 @@ internal static class VisualBriefingPreviewEndpoint // The store re-validates the stored artifact before handing out a stream, so a manually // modified file on disk never reaches the preview: - var preview = await store.OpenValidatedVersionAsync(briefingId, revisionId, cancellationToken); + var preview = await store.OpenIntegrityCheckedVersionAsync(briefingId, revisionId, cancellationToken); if (preview is null) { logger.LogWarning( diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Projects.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Projects.cs index 21d194ea..9feb054d 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Projects.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Projects.cs @@ -373,9 +373,15 @@ public sealed partial class VisualBriefingStore { if (version.VersionNumber <= 0 || version.RevisionId == Guid.Empty || - string.IsNullOrWhiteSpace(version.PayloadHash) || - version.PayloadHash.Length != 64 || - !version.PayloadHash.All(Uri.IsHexDigit) || + version.SchemaVersion <= 0 || + version.IntermediateArtifactVersion < 0 || + version.EvidenceContractVersion < 0 || + version.PlanContractVersion < 0 || + version.ContentContractVersion < 0 || + version.DesignContractVersion < 0 || + string.IsNullOrWhiteSpace(version.DocumentHash) || + version.DocumentHash.Length != 64 || + !version.DocumentHash.All(Uri.IsHexDigit) || !string.Equals( version.FileName, $"{version.VersionNumber:000000}-{version.RevisionId:D}.html", diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Recovery.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Recovery.cs index 006bc43b..aa14f8c6 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Recovery.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Recovery.cs @@ -50,7 +50,7 @@ public sealed partial class VisualBriefingStore stage.Status = VisualBriefingBuildStageStatus.COMPLETED; stage.FinishedAtUtc ??= committedVersion.CreatedAtUtc; - stage.OutputHash = committedVersion.PayloadHash; + stage.OutputHash = committedVersion.DocumentHash; stage.Failure = null; } @@ -126,15 +126,24 @@ public sealed partial class VisualBriefingStore continue; var matchingBuild = builds.FirstOrDefault(build => build.RevisionId == parts.ExportManifest.RevisionId); + var semanticallyCompatible = VisualBriefingArtifactService.TryParseForRecompile(html, out _, out _); manifest.Versions.Add(new() { VersionNumber = versionNumber, + SchemaVersion = parts.ExportManifest.SchemaVersion, + IntermediateArtifactVersion = semanticallyCompatible && matchingBuild is not null + ? VisualBriefingVersions.INTERMEDIATE_ARTIFACT + : 0, + EvidenceContractVersion = semanticallyCompatible ? matchingBuild?.EvidenceContractVersion ?? 0 : 0, + PlanContractVersion = semanticallyCompatible ? matchingBuild?.PlanContractVersion ?? 0 : 0, + ContentContractVersion = semanticallyCompatible ? matchingBuild?.ContentContractVersion ?? 0 : 0, + DesignContractVersion = semanticallyCompatible ? matchingBuild?.DesignContractVersion ?? 0 : 0, RevisionId = parts.ExportManifest.RevisionId, ParentRevisionId = parts.ExportManifest.ParentRevisionId, CreatedAtUtc = parts.ExportManifest.CreatedAtUtc, EditMode = matchingBuild?.Mode ?? VisualBriefingEditMode.IMPORT, Instruction = matchingBuild?.Instruction ?? string.Empty, - PayloadHash = parts.PayloadHash, + DocumentHash = parts.DocumentHash, Origin = "Recovered from disk", FileName = fileName, DataHash = hashes.DataHash, @@ -142,10 +151,10 @@ public sealed partial class VisualBriefingStore TemplateHash = hashes.TemplateHash, CssHash = hashes.CssHash, RuntimeHash = hashes.RuntimeHash, - EvidenceArtifactId = matchingBuild?.EvidenceArtifactId, - PlanArtifactId = matchingBuild?.PlanArtifactId, - ContentArtifactId = matchingBuild?.ContentArtifactId, - PresentationArtifactId = matchingBuild?.PresentationArtifactId, + EvidenceArtifactId = semanticallyCompatible ? matchingBuild?.EvidenceArtifactId : null, + PlanArtifactId = semanticallyCompatible ? matchingBuild?.PlanArtifactId : null, + ContentArtifactId = semanticallyCompatible ? matchingBuild?.ContentArtifactId : null, + PresentationArtifactId = semanticallyCompatible ? matchingBuild?.PresentationArtifactId : null, BuildId = matchingBuild?.BuildId, OperationId = matchingBuild?.OperationId, ModelContributions = BuildRecoveredContributions(matchingBuild), diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Versions.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Versions.cs index 441cdd01..a19152ba 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Versions.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Versions.cs @@ -103,7 +103,7 @@ public sealed partial class VisualBriefingStore CreatedAtUtc = parts.ExportManifest.CreatedAtUtc, EditMode = request.EditMode, Instruction = request.Instruction, - PayloadHash = parts.PayloadHash, + DocumentHash = parts.DocumentHash, Origin = request.Origin, DataHash = hashes.DataHash, AssetHash = hashes.AssetHash, @@ -186,10 +186,10 @@ public sealed partial class VisualBriefingStore return null; var html = await File.ReadAllTextAsync(path, token); - if (!VisualBriefingArtifactService.TryParse(html, out var parts, out _) || + if (!VisualBriefingArtifactService.TryParseForRecompile(html, out var parts, out _) || parts.ExportManifest.BriefingId != briefingId || parts.ExportManifest.RevisionId != revisionId || - !string.Equals(parts.PayloadHash, version.PayloadHash, StringComparison.OrdinalIgnoreCase)) + !string.Equals(parts.DocumentHash, version.DocumentHash, StringComparison.OrdinalIgnoreCase)) return null; return parts; @@ -221,7 +221,7 @@ public sealed partial class VisualBriefingStore if (!VisualBriefingArtifactService.TryParseForRecompile(html, out var parts, out _) || parts.ExportManifest.BriefingId != briefingId || parts.ExportManifest.RevisionId != revisionId || - !string.Equals(parts.PayloadHash, version.PayloadHash, StringComparison.OrdinalIgnoreCase)) + !string.Equals(parts.DocumentHash, version.DocumentHash, StringComparison.OrdinalIgnoreCase)) return null; var hashes = ComputeSectionHashes(parts); @@ -241,10 +241,7 @@ public sealed partial class VisualBriefingStore /// The revision identifier. /// The cancellation token. /// The positioned stream and parsed artifact, or . - public async Task<(FileStream Stream, VisualBriefingArtifactParts Parts)?> OpenValidatedVersionAsync( - Guid briefingId, - Guid revisionId, - CancellationToken token = default) + public async Task<(FileStream Stream, VisualBriefingArtifactParts Parts)?> OpenIntegrityCheckedVersionAsync(Guid briefingId, Guid revisionId, CancellationToken token = default) { var manifest = await this.LoadAsync(briefingId, token); var version = manifest?.Versions.FirstOrDefault(candidate => candidate.RevisionId == revisionId); @@ -260,11 +257,17 @@ public sealed partial class VisualBriefingStore { using var reader = new StreamReader(stream, Encoding.UTF8, true, 65_536, leaveOpen: true); var html = await reader.ReadToEndAsync(token); - if (!VisualBriefingArtifactService.TryParse(html, out var parts, out _) || + if (!VisualBriefingArtifactService.TryParse(html, out var parts, out var issue) || parts.ExportManifest.BriefingId != briefingId || parts.ExportManifest.RevisionId != revisionId || - !string.Equals(parts.PayloadHash, version.PayloadHash, StringComparison.OrdinalIgnoreCase)) + !string.Equals(parts.DocumentHash, version.DocumentHash, StringComparison.OrdinalIgnoreCase)) { + logger.LogWarning( + new EventId((int)VisualBriefingLogEventId.SECURITY_REJECTED, nameof(VisualBriefingLogEventId.SECURITY_REJECTED)), + "Visual briefing document integrity check failed. BriefingId={BriefingId} RevisionId={RevisionId} Issue={Issue}", + briefingId, + revisionId, + string.IsNullOrWhiteSpace(issue) ? "The stored header does not match the requested revision or project manifest." : issue); await stream.DisposeAsync(); return null; } @@ -296,7 +299,7 @@ public sealed partial class VisualBriefingStore if (!importNameConflictAsCopy) return new(false, existing.BriefingId, export.RevisionId, true, false, "The briefing ID exists locally under a different name."); - return await this.ImportCopyAsync(parts, token); + return await this.ImportCopyAsync(html, parts, token); } if (existing is null) @@ -317,9 +320,10 @@ public sealed partial class VisualBriefingStore var knownRevision = existing.Versions.FirstOrDefault(version => version.RevisionId == export.RevisionId); if (knownRevision is not null) { - if (string.Equals(knownRevision.PayloadHash, parts.PayloadHash, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(knownRevision.DocumentHash, parts.DocumentHash, StringComparison.OrdinalIgnoreCase)) { - if (await this.ReadVersionPartsAsync(existing.BriefingId, knownRevision.RevisionId, token) is null) + var storedVersion = await this.OpenIntegrityCheckedVersionAsync(existing.BriefingId, knownRevision.RevisionId, token); + if (storedVersion is null) { await WriteTextAtomicAsync(this.VersionPath(existing.BriefingId, knownRevision), html, token); var restoredHashes = ComputeSectionHashes(parts); @@ -331,41 +335,48 @@ public sealed partial class VisualBriefingStore existing.ModifiedAtUtc = DateTimeOffset.UtcNow; await this.StoreManifestAtomicAsync(existing, token); } + else + await storedVersion.Value.Stream.DisposeAsync(); return new(true, existing.BriefingId, export.RevisionId, false, true, string.Empty); } - return new(false, existing.BriefingId, export.RevisionId, false, false, "The revision ID exists with a different payload hash."); + return new(false, existing.BriefingId, export.RevisionId, false, false, "The revision ID exists with a different document hash."); } var hashes = ComputeSectionHashes(parts); - var importedArtifacts = await this.MaterializeImportedArtifactsAsync( - existing.BriefingId, - parts, - projectLockHeld: true, - token: token); + (VisualBriefingContentArtifact Content, VisualBriefingPresentationArtifact Presentation)? importedArtifacts = null; + + if (VisualBriefingArtifactService.TryParseForRecompile(html, out var compatibleParts, out _)) + importedArtifacts = await this.MaterializeImportedArtifactsAsync(existing.BriefingId, compatibleParts, projectLockHeld: true, token: token); var version = new VisualBriefingVersion { VersionNumber = this.NextVersionNumber(existing), + SchemaVersion = export.SchemaVersion, + IntermediateArtifactVersion = importedArtifacts is null ? 0 : VisualBriefingVersions.INTERMEDIATE_ARTIFACT, + EvidenceContractVersion = importedArtifacts is null ? 0 : VisualBriefingVersions.EVIDENCE_CONTRACT, + PlanContractVersion = importedArtifacts is null ? 0 : VisualBriefingVersions.PLAN_CONTRACT, + ContentContractVersion = importedArtifacts is null ? 0 : VisualBriefingVersions.CONTENT_CONTRACT, + DesignContractVersion = importedArtifacts is null ? 0 : VisualBriefingVersions.DESIGN_CONTRACT, RevisionId = export.RevisionId, ParentRevisionId = export.ParentRevisionId, CreatedAtUtc = export.CreatedAtUtc, EditMode = VisualBriefingEditMode.IMPORT, - PayloadHash = parts.PayloadHash, + DocumentHash = parts.DocumentHash, Origin = Path.GetFileName(sourcePath), DataHash = hashes.DataHash, AssetHash = hashes.AssetHash, TemplateHash = hashes.TemplateHash, CssHash = hashes.CssHash, RuntimeHash = hashes.RuntimeHash, - ContentArtifactId = importedArtifacts.Content.ArtifactId, - PresentationArtifactId = importedArtifacts.Presentation.ArtifactId, - ModelContributions = + ContentArtifactId = importedArtifacts?.Content.ArtifactId, + PresentationArtifactId = importedArtifacts?.Presentation.ArtifactId, + ModelContributions = importedArtifacts is { } artifacts ? [ - new(VisualBriefingModelRole.CONTENT, importedArtifacts.Content.Model), - new(VisualBriefingModelRole.DESIGN, importedArtifacts.Presentation.Model), - ], + new(VisualBriefingModelRole.CONTENT, artifacts.Content.Model), + new(VisualBriefingModelRole.DESIGN, artifacts.Presentation.Model), + ] : [], }; version.FileName = $"{version.VersionNumber:000000}-{version.RevisionId:D}.html"; @@ -389,8 +400,11 @@ public sealed partial class VisualBriefingStore /// /// Defines ImportCopyAsync for the visual briefing feature. /// - private async Task ImportCopyAsync(VisualBriefingArtifactParts parts, CancellationToken token) + private async Task ImportCopyAsync(string html, VisualBriefingArtifactParts parts, CancellationToken token) { + if (!VisualBriefingArtifactService.TryParseForRecompile(html, out parts, out _)) + return new(false, Guid.Empty, Guid.Empty, false, false, "This historical briefing can be imported under its original identity, but it cannot be rewritten as a copy with the current compiler."); + var copyId = Guid.NewGuid(); var manifest = await this.CreateAsync( parts.ExportManifest.Name, diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingVersion.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingVersion.cs index bccc411b..5098a559 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingVersion.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingVersion.cs @@ -5,6 +5,24 @@ namespace AIStudio.Assistants.VisualBriefing; /// public sealed class VisualBriefingVersion { + /// Gets or sets the canonical data schema used by this revision. + public int SchemaVersion { get; set; } = VisualBriefingVersions.SCHEMA; + + /// Gets or sets the semantic intermediate-artifact format. + public int IntermediateArtifactVersion { get; set; } = VisualBriefingVersions.INTERMEDIATE_ARTIFACT; + + /// Gets or sets the evidence contract used by this revision. + public int EvidenceContractVersion { get; set; } = VisualBriefingVersions.EVIDENCE_CONTRACT; + + /// Gets or sets the plan contract used by this revision. + public int PlanContractVersion { get; set; } = VisualBriefingVersions.PLAN_CONTRACT; + + /// Gets or sets the content contract used by this revision. + public int ContentContractVersion { get; set; } = VisualBriefingVersions.CONTENT_CONTRACT; + + /// Gets or sets the design contract used by this revision. + public int DesignContractVersion { get; set; } = VisualBriefingVersions.DESIGN_CONTRACT; + /// /// Defines VersionNumber for the visual briefing feature. /// @@ -36,9 +54,9 @@ public sealed class VisualBriefingVersion public string Instruction { get; set; } = string.Empty; /// - /// Defines PayloadHash for the visual briefing feature. + /// Gets or sets the SHA-256 hash of the complete standalone HTML document. /// - public string PayloadHash { get; set; } = string.Empty; + public string DocumentHash { get; set; } = string.Empty; /// /// Defines Origin for the visual briefing feature.