Replaced payload hash with document hash for better integrity

This commit is contained in:
Thorsten Sommer 2026-07-31 15:45:33 +02:00
parent 868a8b1cf3
commit d0f858c77d
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
13 changed files with 331 additions and 325 deletions

View File

@ -11,7 +11,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// <param name="Css">The safe presentation stylesheet.</param>
/// <param name="RuntimeScript">The embedded AI Studio runtime.</param>
/// <param name="EChartsScript">The optional embedded Apache ECharts runtime.</param>
/// <param name="PayloadHash">The protected payload hash.</param>
/// <param name="DocumentHash">The SHA-256 hash of the complete standalone document.</param>
public sealed record VisualBriefingArtifactParts(
VisualBriefingExportManifest ExportManifest,
JsonElement Data,
@ -19,4 +19,4 @@ public sealed record VisualBriefingArtifactParts(
string Css,
string RuntimeScript,
string? EChartsScript,
string PayloadHash);
string DocumentHash);

View File

@ -25,12 +25,7 @@ public sealed partial class VisualBriefingArtifactService
/// <param name="lockedEChartsScript">An existing chart runtime to reuse, keeping a revision reproducible.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The complete standalone HTML document.</returns>
public Task<string> BuildAsync(
VisualBriefingManifest manifest,
VisualBriefingRevisionRequest request,
string? lockedRuntimeScript = null,
string? lockedEChartsScript = null,
CancellationToken token = default)
public Task<string> 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($"""
<!doctype html>
<html lang="{htmlLanguage}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="{csp}">
<meta name="referrer" content="no-referrer">
<title>{HtmlEncode(briefingName)}</title>
<style id="mwai-briefing-style">{css}
{PROTECTED_FOOTER_CSS}</style>
</head>
<body>
<!--{MANIFEST_MARKER}{encodedManifest}-->
<script id="{DATA_ELEMENT_ID}" type="application/json">{dataJson}</script>
<div id="mwai-briefing-root">{template}</div>
<footer id="mwai-static-footer" class="mwai-footer">
{STATIC_FOOTER_TEMPLATE}
</footer>
{BuildScriptTag(echarts, "mwai-echarts-runtime")}
<script id="mwai-briefing-runtime">{runtime}</script>
</body>
</html>
""");
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));
}
/// <summary>
/// Assembles the deterministic document around a supplied artifact header.
/// </summary>
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 $"""
<!doctype html>
<!--{HEADER_MARKER}{encodedHeader}-->
<html lang="{htmlLanguage}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="{csp}">
<meta name="referrer" content="no-referrer">
<title>{HtmlEncode(briefingName)}</title>
<style id="mwai-briefing-style">{css}</style>
<style id="mwai-protected-style">{PROTECTED_FOOTER_CSS}</style>
</head>
<body>
<script id="{DATA_ELEMENT_ID}" type="application/json">{dataJson}</script>
<div id="mwai-briefing-root">{template}</div>
<footer id="mwai-static-footer" class="mwai-footer">
{STATIC_FOOTER_TEMPLATE}
</footer>
{BuildScriptTag(echarts, "mwai-echarts-runtime")}
<script id="mwai-briefing-runtime">{runtime}</script>
</body>
</html>
""";
}
/// <summary>
/// Encodes the stable JSON artifact header for embedding in an HTML comment.
/// </summary>
private static string EncodeHeader(VisualBriefingExportManifest exportManifest) => Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(exportManifest, JSON_OPTIONS)));
/// <summary>
/// Defines <c>RuntimeAIVersionRegex</c> for the visual briefing feature.
/// </summary>
@ -109,7 +114,7 @@ public sealed partial class VisualBriefingArtifactService
/// Defines the protected, app-owned static footer template.
/// </summary>
private const string STATIC_FOOTER_TEMPLATE = """
<span data-mwai-text="_mwai.footer.createdWith"></span>
<span>Created with <a href="https://github.com/MindWorkAI/AI-Studio" target="_blank" rel="noopener noreferrer">MindWork AI Studio</a> v<span data-mwai-text="_mwai.aiStudioVersion"></span>.</span>
<span data-mwai-text="_mwai.footer.models"></span>
<span data-mwai-text="_mwai.footer.createdAt"></span>
<span data-mwai-text="_mwai.footer.authors"></span>
@ -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'";
}
/// <summary>
/// Defines <c>ComputePayloadHash</c> for the visual briefing feature.
/// </summary>
private static string ComputePayloadHash(string dataJson, string template, string css, string runtime, string? echarts) =>
VisualBriefingHashing.ComputeSections(dataJson, template, css, runtime, echarts);
/// <summary>
/// Defines <c>ScriptCspHash</c> for the visual briefing feature.
/// </summary>
@ -238,12 +246,7 @@ public sealed partial class VisualBriefingArtifactService
/// <summary>
/// Defines <c>CreateExportManifest</c> for the visual briefing feature.
/// </summary>
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,
};
}

View File

@ -9,195 +9,147 @@ namespace AIStudio.Assistants.VisualBriefing;
public sealed partial class VisualBriefingArtifactService
{
/// <summary>
/// Defines <c>ManifestRegex</c> for the visual briefing feature.
/// Matches the version-independent artifact header at the start of standalone HTML.
/// </summary>
private static readonly Regex MANIFEST_REGEX = ManifestRegex();
private static readonly Regex HEADER_REGEX = HeaderRegex();
/// <summary>
/// Defines <c>ManifestRegex</c> for the visual briefing feature.
/// Matches the version-independent artifact header at the start of standalone HTML.
/// </summary>
[GeneratedRegex("<!--MWAI_VISUAL_BRIEFING_MANIFEST:(?<value>[A-Za-z0-9+/=]+)-->", RegexOptions.CultureInvariant)]
private static partial Regex ManifestRegex();
[GeneratedRegex(@"\A<!doctype html>\n<!--MWAI_VISUAL_BRIEFING_HEADER:(?<value>[A-Za-z0-9+/=]+)-->\n", RegexOptions.CultureInvariant)]
private static partial Regex HeaderRegex();
/// <summary>
/// Defines <c>StyleRegex</c> for the visual briefing feature.
/// Matches the generated presentation stylesheet.
/// </summary>
private static readonly Regex STYLE_REGEX = StyleRegex();
/// <summary>
/// Defines <c>StyleRegex</c> for the visual briefing feature.
/// Matches the generated presentation stylesheet.
/// </summary>
[GeneratedRegex("""<style\s+id="mwai-briefing-style">(?<value>[\s\S]*?)</style>""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex StyleRegex();
/// <summary>
/// Defines <c>RuntimeRegex</c> for the visual briefing feature.
/// Matches the embedded declarative runtime.
/// </summary>
private static readonly Regex RUNTIME_REGEX = RuntimeRegex();
/// <summary>
/// Defines <c>RuntimeRegex</c> for the visual briefing feature.
/// Matches the embedded declarative runtime.
/// </summary>
[GeneratedRegex("""<script\s+id="mwai-briefing-runtime">(?<value>[\s\S]*?)</script>""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex RuntimeRegex();
/// <summary>
/// Defines <c>EChartsRegex</c> for the visual briefing feature.
/// Matches the optional embedded chart runtime.
/// </summary>
private static readonly Regex ECHARTS_REGEX = EChartsRegex();
/// <summary>
/// Defines <c>EChartsRegex</c> for the visual briefing feature.
/// Matches the optional embedded chart runtime.
/// </summary>
[GeneratedRegex("""<script\s+id="mwai-echarts-runtime">(?<value>[\s\S]*?)</script>""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex EChartsRegex();
/// <summary>
/// Parses a standalone artifact using the current runtime contract.
/// Reads an intact standalone artifact without applying current compiler or runtime rules.
/// </summary>
/// <param name="html">The complete standalone HTML document.</param>
/// <param name="parts">The validated artifact parts.</param>
/// <param name="issue">The user-safe validation issue.</param>
/// <returns>Whether the artifact is valid for the current runtime.</returns>
public static bool TryParse(string html, out VisualBriefingArtifactParts parts, out string issue) => TryParse(html, allowOutdatedRuntime: false, out parts, out issue);
/// <summary>
/// Parses a locally stored parent artifact for recompilation while allowing a previous runtime
/// bundle that will be discarded before the new revision is assembled.
/// </summary>
/// <param name="html">The complete standalone HTML document.</param>
/// <param name="parts">The validated artifact parts.</param>
/// <param name="issue">The user-safe validation issue.</param>
/// <returns>Whether the artifact is structurally valid for recompilation.</returns>
internal static bool TryParseForRecompile(string html, out VisualBriefingArtifactParts parts, out string issue) => TryParse(html, allowOutdatedRuntime: true, out parts, out issue);
/// <summary>
/// Parses and validates a standalone artifact under the selected runtime policy.
/// </summary>
/// <param name="html">The complete standalone HTML document.</param>
/// <param name="allowOutdatedRuntime">Whether a previous runtime bundle may be read but never reused.</param>
/// <param name="parts">The validated artifact parts.</param>
/// <param name="issue">The user-safe validation issue.</param>
/// <returns>Whether the artifact passed all applicable checks.</returns>
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("<!doctype html>\n", StringComparison.Ordinal) ||
!html.EndsWith("</html>", StringComparison.Ordinal))
if (!html.EndsWith("</html>", 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<VisualBriefingExportManifest>(JSON_OPTIONS);
: headerDocument.RootElement.Deserialize<VisualBriefingExportManifest>(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 = $"<!doctype html>\n<!--{HEADER_MARKER}{EncodeHeader(exportManifest)}-->\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<string>(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,
$"<!--{MANIFEST_MARKER}{manifestMatch.Groups["value"].Value}-->",
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;
}
/// <summary>
/// Defines <c>HasExactAttributes</c> for the visual briefing feature.
/// Reads an intact artifact and additionally applies the current semantic compiler contract.
/// </summary>
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;
}
/// <summary>
/// Defines <c>ValidateProtectedData</c> for the visual briefing feature.
/// Validates stable artifact-header fields without imposing current runtime or schema versions.
/// </summary>
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;
}
/// <summary>
/// Finds exactly one node for an XPath expression.
/// </summary>
private static HtmlNode? FindUniqueNode(HtmlDocument document, string xpath)
{
var nodes = FindNodes(document.DocumentNode, xpath)?.ToArray() ?? [];
return nodes.Length == 1 ? nodes[0] : null;
}
/// <summary>
/// Finds exactly one element by ID.
/// </summary>
private static HtmlNode? FindUniqueElementById(HtmlDocument document, string id)
{
var nodes = FindNodes(document.DocumentNode, $"//*[@id='{id}']")?.ToArray() ?? [];
return nodes.Length == 1 ? nodes[0] : null;
}
/// <summary>
/// Validates current protected data needed for recompilation.
/// </summary>
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 =>

View File

@ -15,9 +15,14 @@ namespace AIStudio.Assistants.VisualBriefing;
public sealed partial class VisualBriefingArtifactService
{
/// <summary>
/// Marks the Base64 compatibility manifest embedded in standalone HTML.
/// Marks the Base64 artifact header embedded at the start of standalone HTML.
/// </summary>
private const string MANIFEST_MARKER = "MWAI_VISUAL_BRIEFING_MANIFEST:";
private const string HEADER_MARKER = "MWAI_VISUAL_BRIEFING_HEADER:";
/// <summary>
/// Breaks the circular dependency while hashing a document that carries its own hash.
/// </summary>
private const string DOCUMENT_HASH_PLACEHOLDER = "0000000000000000000000000000000000000000000000000000000000000000";
/// <summary>
/// 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);
}
/// <summary>
/// Defines <c>GetHtmlLanguage</c> for the visual briefing feature.
/// </summary>
private static string GetHtmlLanguage(VisualBriefingLocalSettings settings) =>
GetHtmlLanguage(settings.TargetLanguage, settings.CustomTargetLanguage);
/// <summary>
/// Defines <c>GetHtmlLanguage</c> for the visual briefing feature.
/// </summary>

View File

@ -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);

View File

@ -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(

View File

@ -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)

View File

@ -108,7 +108,7 @@ public sealed class VisualBriefingExportManifest
public string RuntimeAIStudioVersion { get; set; } = string.Empty;
/// <summary>
/// Defines <c>PayloadHash</c> for the visual briefing feature.
/// Gets or sets the SHA-256 hash of the complete standalone HTML document.
/// </summary>
public string PayloadHash { get; set; } = string.Empty;
public string DocumentHash { get; set; } = string.Empty;
}

View File

@ -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(

View File

@ -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",

View File

@ -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),

View File

@ -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
/// <param name="revisionId">The revision identifier.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The positioned stream and parsed artifact, or <see langword="null"/>.</returns>
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
/// <summary>
/// Defines <c>ImportCopyAsync</c> for the visual briefing feature.
/// </summary>
private async Task<VisualBriefingImportResult> ImportCopyAsync(VisualBriefingArtifactParts parts, CancellationToken token)
private async Task<VisualBriefingImportResult> 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,

View File

@ -5,6 +5,24 @@ namespace AIStudio.Assistants.VisualBriefing;
/// </summary>
public sealed class VisualBriefingVersion
{
/// <summary>Gets or sets the canonical data schema used by this revision.</summary>
public int SchemaVersion { get; set; } = VisualBriefingVersions.SCHEMA;
/// <summary>Gets or sets the semantic intermediate-artifact format.</summary>
public int IntermediateArtifactVersion { get; set; } = VisualBriefingVersions.INTERMEDIATE_ARTIFACT;
/// <summary>Gets or sets the evidence contract used by this revision.</summary>
public int EvidenceContractVersion { get; set; } = VisualBriefingVersions.EVIDENCE_CONTRACT;
/// <summary>Gets or sets the plan contract used by this revision.</summary>
public int PlanContractVersion { get; set; } = VisualBriefingVersions.PLAN_CONTRACT;
/// <summary>Gets or sets the content contract used by this revision.</summary>
public int ContentContractVersion { get; set; } = VisualBriefingVersions.CONTENT_CONTRACT;
/// <summary>Gets or sets the design contract used by this revision.</summary>
public int DesignContractVersion { get; set; } = VisualBriefingVersions.DESIGN_CONTRACT;
/// <summary>
/// Defines <c>VersionNumber</c> for the visual briefing feature.
/// </summary>
@ -36,9 +54,9 @@ public sealed class VisualBriefingVersion
public string Instruction { get; set; } = string.Empty;
/// <summary>
/// Defines <c>PayloadHash</c> for the visual briefing feature.
/// Gets or sets the SHA-256 hash of the complete standalone HTML document.
/// </summary>
public string PayloadHash { get; set; } = string.Empty;
public string DocumentHash { get; set; } = string.Empty;
/// <summary>
/// Defines <c>Origin</c> for the visual briefing feature.