mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 20:12:12 +00:00
Replaced payload hash with document hash for better integrity
This commit is contained in:
parent
868a8b1cf3
commit
d0f858c77d
@ -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);
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 =>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user