3006 lines
141 KiB
C#
3006 lines
141 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using Lskj.CommandKernel;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace Lskj.AgentBridge
|
|
{
|
|
public sealed class CustomerAcceptanceBundleVerificationResult
|
|
{
|
|
public string ContentSha256 { get; set; }
|
|
public string SourceCommit { get; set; }
|
|
public string PackageSha256 { get; set; }
|
|
public string RuntimeConfigurationSha256 { get; set; }
|
|
public string CustomerProfileSha256 { get; set; }
|
|
public string RolloutPolicySha256 { get; set; }
|
|
public string RolloutCustomerId { get; set; }
|
|
public string DatabaseScopeFingerprint { get; set; }
|
|
public string RuntimeCliVersion { get; set; }
|
|
public string RuntimeCliSha256 { get; set; }
|
|
public string RuntimeCliSignerThumbprint { get; set; }
|
|
public string VerifierCliSha256 { get; set; }
|
|
public string VerifierSignerThumbprint { get; set; }
|
|
public string PurchaseReadOnlySessionPreflightGeneratedAtUtc { get; set; }
|
|
public string PurchaseFieldReadOnlyValidationHandoffGeneratedAtUtc { get; set; }
|
|
public int PurchaseReadOnlySessionErpProcessId { get; set; }
|
|
public string PurchaseReadOnlySessionErpSha256 { get; set; }
|
|
public string PurchaseReadOnlySessionErpSignerThumbprint { get; set; }
|
|
public string LeaveReadOnlySessionPreflightGeneratedAtUtc { get; set; }
|
|
public string LeaveFieldReadOnlyValidationHandoffGeneratedAtUtc { get; set; }
|
|
public int LeaveReadOnlySessionErpProcessId { get; set; }
|
|
public string LeaveReadOnlySessionErpSha256 { get; set; }
|
|
public string LeaveReadOnlySessionErpSignerThumbprint { get; set; }
|
|
public string DiagnosisReadOnlySessionPreflightGeneratedAtUtc { get; set; }
|
|
public string DiagnosisFieldReadOnlyValidationHandoffGeneratedAtUtc { get; set; }
|
|
public int DiagnosisReadOnlySessionErpProcessId { get; set; }
|
|
public string DiagnosisReadOnlySessionErpSha256 { get; set; }
|
|
public string DiagnosisReadOnlySessionErpSignerThumbprint { get; set; }
|
|
public string PurchaseWorkflowUatAuthorizationSourceSha256 { get; set; }
|
|
public string PurchaseWorkflowUatAuthorizationContentSha256 { get; set; }
|
|
public string PurchaseWorkflowUatAuthorizationIdSha256 { get; set; }
|
|
public string LeaveWorkflowUatAuthorizationSourceSha256 { get; set; }
|
|
public string LeaveWorkflowUatAuthorizationContentSha256 { get; set; }
|
|
public string LeaveWorkflowUatAuthorizationIdSha256 { get; set; }
|
|
public string MiniMaxVisionProbeEvidenceSha256 { get; set; }
|
|
public string MiniMaxVisionProbeObservedAtUtc { get; set; }
|
|
public string MiniMaxVisionProbeRegion { get; set; }
|
|
public string MiniMaxVisionProbeContractVersion { get; set; }
|
|
public string AccountBook { get; set; }
|
|
public string PurchaseSubSystemId { get; set; }
|
|
public string LeaveSubSystemId { get; set; }
|
|
public string DiagnosisSubSystemId { get; set; }
|
|
public string PurchaseModuleCode { get; set; }
|
|
public string LeaveModuleCode { get; set; }
|
|
public DateTime IssuedAtUtc { get; set; }
|
|
public DateTime ExpiresAtUtc { get; set; }
|
|
public string ValidatedBy { get; set; }
|
|
public string CertificateThumbprint { get; set; }
|
|
|
|
public IDictionary<string, object> ToDictionary()
|
|
{
|
|
return new Dictionary<string, object>
|
|
{
|
|
{ "packageType", "customer_commercial_acceptance_bundle" },
|
|
{ "schemaVersion", "1.8" },
|
|
{ "contentSha256", ContentSha256 },
|
|
{ "sourceCommit", SourceCommit },
|
|
{ "packageSha256", PackageSha256 },
|
|
{ "runtimeConfigurationSha256", RuntimeConfigurationSha256 },
|
|
{ "customerProfileSha256", CustomerProfileSha256 },
|
|
{ "rolloutPolicySha256", RolloutPolicySha256 },
|
|
{ "rolloutCustomerId", RolloutCustomerId },
|
|
{ "runtimeCliVersion", RuntimeCliVersion },
|
|
{ "runtimeCliSha256", RuntimeCliSha256 },
|
|
{ "runtimeCliSignerThumbprint", RuntimeCliSignerThumbprint },
|
|
{ "verifierCliSha256", VerifierCliSha256 },
|
|
{ "verifierSignerThumbprint", VerifierSignerThumbprint },
|
|
{ "purchaseWorkflowUatAuthorizationSourceSha256",
|
|
PurchaseWorkflowUatAuthorizationSourceSha256 },
|
|
{ "purchaseWorkflowUatAuthorizationContentSha256",
|
|
PurchaseWorkflowUatAuthorizationContentSha256 },
|
|
{ "purchaseWorkflowUatAuthorizationIdSha256",
|
|
PurchaseWorkflowUatAuthorizationIdSha256 },
|
|
{ "leaveWorkflowUatAuthorizationSourceSha256",
|
|
LeaveWorkflowUatAuthorizationSourceSha256 },
|
|
{ "leaveWorkflowUatAuthorizationContentSha256",
|
|
LeaveWorkflowUatAuthorizationContentSha256 },
|
|
{ "leaveWorkflowUatAuthorizationIdSha256",
|
|
LeaveWorkflowUatAuthorizationIdSha256 },
|
|
{ "miniMaxVisionProbeEvidenceSha256", MiniMaxVisionProbeEvidenceSha256 },
|
|
{ "miniMaxVisionProbeObservedAtUtc", MiniMaxVisionProbeObservedAtUtc },
|
|
{ "miniMaxVisionProbeRegion", MiniMaxVisionProbeRegion },
|
|
{ "miniMaxVisionProbeContractVersion", MiniMaxVisionProbeContractVersion },
|
|
{ "erpScope", new Dictionary<string, object>
|
|
{
|
|
{ "accountBook", AccountBook },
|
|
{ "databaseScopeFingerprint", DatabaseScopeFingerprint }
|
|
}
|
|
},
|
|
{ "sessionScopes", new Dictionary<string, object>
|
|
{
|
|
{ "purchase", SessionScope(
|
|
PurchaseSubSystemId,
|
|
PurchaseReadOnlySessionPreflightGeneratedAtUtc,
|
|
PurchaseFieldReadOnlyValidationHandoffGeneratedAtUtc,
|
|
PurchaseReadOnlySessionErpProcessId,
|
|
PurchaseReadOnlySessionErpSha256,
|
|
PurchaseReadOnlySessionErpSignerThumbprint) },
|
|
{ "leave", SessionScope(
|
|
LeaveSubSystemId,
|
|
LeaveReadOnlySessionPreflightGeneratedAtUtc,
|
|
LeaveFieldReadOnlyValidationHandoffGeneratedAtUtc,
|
|
LeaveReadOnlySessionErpProcessId,
|
|
LeaveReadOnlySessionErpSha256,
|
|
LeaveReadOnlySessionErpSignerThumbprint) },
|
|
{ "diagnosis", SessionScope(
|
|
DiagnosisSubSystemId,
|
|
DiagnosisReadOnlySessionPreflightGeneratedAtUtc,
|
|
DiagnosisFieldReadOnlyValidationHandoffGeneratedAtUtc,
|
|
DiagnosisReadOnlySessionErpProcessId,
|
|
DiagnosisReadOnlySessionErpSha256,
|
|
DiagnosisReadOnlySessionErpSignerThumbprint) }
|
|
}
|
|
},
|
|
{ "workflows", new Dictionary<string, object>
|
|
{
|
|
{ "purchase", PurchaseModuleCode },
|
|
{ "leave", LeaveModuleCode }
|
|
}
|
|
},
|
|
{ "diagnosticScenarioCount", 4 },
|
|
{ "artifactCount", 23 },
|
|
{ "issuedAtUtc", IssuedAtUtc },
|
|
{ "expiresAtUtc", ExpiresAtUtc },
|
|
{ "validatedBy", ValidatedBy },
|
|
{ "certificateThumbprint", CertificateThumbprint },
|
|
{ "signatureVerified", true },
|
|
{ "allComponentEvidenceVerified", true },
|
|
{ "registrationReady", false },
|
|
{ "note", "总验收包通过后仍须由 ERP 在启动和写入前复核当前 V2 就绪行、运行时配置及签名画像的只读系统目录。" }
|
|
};
|
|
}
|
|
|
|
private static IDictionary<string, object> SessionScope(
|
|
string subSystemId,
|
|
string preflightGeneratedAtUtc,
|
|
string handoffGeneratedAtUtc,
|
|
int erpProcessId,
|
|
string erpSha256,
|
|
string erpSignerThumbprint)
|
|
{
|
|
return new Dictionary<string, object>
|
|
{
|
|
{ "subSystemId", subSystemId },
|
|
{ "readOnlySessionPreflightGeneratedAtUtc", preflightGeneratedAtUtc },
|
|
{ "fieldReadOnlyValidationHandoffGeneratedAtUtc", handoffGeneratedAtUtc },
|
|
{ "erpProcessId", erpProcessId },
|
|
{ "erpSha256", erpSha256 },
|
|
{ "erpSignerThumbprint", erpSignerThumbprint }
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 离线验证客户商用总验收包及其二十三个原始制品。总包把最终 ZIP、源码提交、
|
|
/// 运行时配置、客户只读画像、三个独立 ERP 会话的动态参数合同、命令发布策略、
|
|
/// 两条写链路和四类初始化诊断绑定到同一客户数据库作用域。
|
|
/// </summary>
|
|
public static class CustomerAcceptanceBundleEvidenceVerifier
|
|
{
|
|
private const int MaximumBundleBytes = 1024 * 1024;
|
|
private static readonly Regex Sha256Pattern = new Regex(
|
|
@"^[a-f0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex CommitPattern = new Regex(
|
|
@"^[a-f0-9]{40}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeModule = new Regex(
|
|
@"^[A-Za-z0-9_.:\-]{1,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeIdentifier = new Regex(
|
|
@"^[A-Za-z0-9_.:\-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeProductVersion = new Regex(
|
|
@"^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeReadinessCode = new Regex(
|
|
@"^[a-z0-9_.\-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeFileName = new Regex(
|
|
@"^[A-Za-z0-9][A-Za-z0-9_.\-]{0,127}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
|
|
private static readonly string[] ArtifactNames =
|
|
{
|
|
"package", "runtimeConfiguration", "rolloutPolicy", "commercialPreflight",
|
|
"purchaseReadOnlySessionPreflight",
|
|
"purchaseFieldReadOnlyValidationHandoff",
|
|
"leaveReadOnlySessionPreflight",
|
|
"leaveFieldReadOnlyValidationHandoff",
|
|
"diagnosisReadOnlySessionPreflight",
|
|
"diagnosisFieldReadOnlyValidationHandoff",
|
|
"miniMaxVisionProbe",
|
|
"customerProfile", "legacyBuildEvidence", "purchaseAcceptance",
|
|
"purchaseWorkflowUatAuthorization",
|
|
"purchaseWriteIntegration",
|
|
"leaveWorkflowUatAuthorization",
|
|
"leaveAcceptance", "leaveWriteIntegration", "diagnosticMissingField",
|
|
"diagnosticPermissionDenied", "diagnosticInvalidRelation",
|
|
"diagnosticInitializationSqlFailure"
|
|
};
|
|
|
|
private static readonly string[] RequiredPreflightChecks =
|
|
{
|
|
"windows_os", "package_hashes", "package_source_provenance",
|
|
"package_archive_binding",
|
|
"sqlserver_compatibility100_syntax",
|
|
"legacy_build_tool", "legacy_build_contract_tests",
|
|
"workflow_write_evidence_tools", "workflow_uat_case_catalog",
|
|
"legacy_build_evidence",
|
|
"legacy_erp_authenticode", "legacy_cli_authenticode",
|
|
"legacy_bridge_authenticode", "desktop_host", "host_authenticode",
|
|
"bridge_cli_runtime_identity",
|
|
"webview2_runtime", "guga_supply_chain_audit", "guga_sprite",
|
|
"guga_commercial_license_evidence",
|
|
"astrbot_agpl_eula_compliance_evidence",
|
|
"minimax_api_service_compliance_evidence",
|
|
"minimax_online_vision_probe_evidence",
|
|
"astrbot_credential", "desktop_secret_environment", "astrbot_transport",
|
|
"astrbot_reachable", "astrbot_runtime_contract",
|
|
"rollout_policy_file", "erp_agent_bridge",
|
|
"erp_rollout_policy", "erp_session_scope", "astrbot_plugin_layout",
|
|
"astrbot_plugin_contract", "minimax_direct_https_vlm",
|
|
"pdf_invoice_pipeline",
|
|
"attachment_snapshot_binding", "erp_wire_contract"
|
|
};
|
|
|
|
public static CustomerAcceptanceBundleVerificationResult VerifyFile(
|
|
string bundlePath,
|
|
string evidenceRoot,
|
|
string expectedSourceCommit,
|
|
string expectedPackageSha256,
|
|
string expectedAccountBook,
|
|
string expectedSubSystemId,
|
|
IAcceptanceSignatureVerifier signatureVerifier,
|
|
DateTime nowUtc)
|
|
{
|
|
return VerifyFile(
|
|
bundlePath,
|
|
evidenceRoot,
|
|
expectedSourceCommit,
|
|
expectedPackageSha256,
|
|
expectedAccountBook,
|
|
expectedSubSystemId,
|
|
expectedSubSystemId,
|
|
expectedSubSystemId,
|
|
null,
|
|
signatureVerifier,
|
|
nowUtc);
|
|
}
|
|
|
|
public static CustomerAcceptanceBundleVerificationResult VerifyFile(
|
|
string bundlePath,
|
|
string evidenceRoot,
|
|
string expectedSourceCommit,
|
|
string expectedPackageSha256,
|
|
string expectedAccountBook,
|
|
string expectedSubSystemId,
|
|
string expectedDatabaseScopeFingerprint,
|
|
IAcceptanceSignatureVerifier signatureVerifier,
|
|
DateTime nowUtc)
|
|
{
|
|
return VerifyFile(
|
|
bundlePath,
|
|
evidenceRoot,
|
|
expectedSourceCommit,
|
|
expectedPackageSha256,
|
|
expectedAccountBook,
|
|
expectedSubSystemId,
|
|
expectedSubSystemId,
|
|
expectedSubSystemId,
|
|
expectedDatabaseScopeFingerprint,
|
|
signatureVerifier,
|
|
nowUtc);
|
|
}
|
|
|
|
public static CustomerAcceptanceBundleVerificationResult VerifyFile(
|
|
string bundlePath,
|
|
string evidenceRoot,
|
|
string expectedSourceCommit,
|
|
string expectedPackageSha256,
|
|
string expectedAccountBook,
|
|
string expectedPurchaseSubSystemId,
|
|
string expectedLeaveSubSystemId,
|
|
string expectedDiagnosisSubSystemId,
|
|
string expectedDatabaseScopeFingerprint,
|
|
IAcceptanceSignatureVerifier signatureVerifier,
|
|
DateTime nowUtc)
|
|
{
|
|
if (signatureVerifier == null) throw new ArgumentNullException("signatureVerifier");
|
|
expectedDatabaseScopeFingerprint = string.IsNullOrWhiteSpace(
|
|
expectedDatabaseScopeFingerprint)
|
|
? null
|
|
: expectedDatabaseScopeFingerprint.Trim().ToLowerInvariant();
|
|
if (expectedDatabaseScopeFingerprint != null
|
|
&& !Sha256Pattern.IsMatch(expectedDatabaseScopeFingerprint))
|
|
throw Error(
|
|
"customer_acceptance_bundle_input_invalid",
|
|
"预期数据库作用域指纹格式无效。");
|
|
nowUtc = nowUtc.Kind == DateTimeKind.Utc ? nowUtc : nowUtc.ToUniversalTime();
|
|
string rootPath = GetEvidenceRoot(evidenceRoot);
|
|
JObject envelope = LoadStrict(bundlePath, MaximumBundleBytes, "总验收包");
|
|
EnsureExact(envelope,
|
|
"schemaVersion", "contentSha256", "signatureAlgorithm",
|
|
"certificateThumbprint", "signatureBase64", "content");
|
|
if (RequiredString(envelope, "schemaVersion", 1, 16) != "1.8"
|
|
|| RequiredString(envelope, "signatureAlgorithm", 1, 32) != "rsa-sha256")
|
|
throw Error("customer_acceptance_bundle_schema_invalid", "总验收包版本或签名算法无效。");
|
|
string contentHash = RequiredHash(envelope, "contentSha256");
|
|
string thumbprint = RequiredString(envelope, "certificateThumbprint", 40, 64);
|
|
string normalizedThumbprint = WindowsTrustedPeopleSignatureVerifier.NormalizeThumbprint(
|
|
thumbprint);
|
|
if (normalizedThumbprint == null)
|
|
throw Error("customer_acceptance_bundle_schema_invalid", "总验收包证书指纹无效。");
|
|
byte[] signature;
|
|
try
|
|
{
|
|
signature = Convert.FromBase64String(
|
|
RequiredString(envelope, "signatureBase64", 32, 4096));
|
|
}
|
|
catch
|
|
{
|
|
throw Error("customer_acceptance_bundle_signature_invalid", "总验收包签名不是有效 Base64。");
|
|
}
|
|
JObject content = RequiredObject(envelope, "content");
|
|
EnsureExact(content,
|
|
"packageType", "sourceCommit", "packageSha256", "erpScope",
|
|
"sessionScopes",
|
|
"runtimeConfigurationSha256", "customerProfileSha256",
|
|
"commercialPreflightGeneratedAtUtc",
|
|
"miniMaxVisionProbeEvidenceSha256",
|
|
"miniMaxVisionProbeObservedAtUtc", "miniMaxVisionProbeRegion",
|
|
"miniMaxVisionProbeContractVersion",
|
|
"rolloutPolicySha256", "rolloutCustomerId", "workflows",
|
|
"runtimeCliVersion", "runtimeCliSha256",
|
|
"runtimeCliSignerThumbprint",
|
|
"verifierCliSha256", "verifierSignerThumbprint",
|
|
"purchaseWorkflowUatAuthorizationSourceSha256",
|
|
"purchaseWorkflowUatAuthorizationContentSha256",
|
|
"purchaseWorkflowUatAuthorizationIdSha256",
|
|
"leaveWorkflowUatAuthorizationSourceSha256",
|
|
"leaveWorkflowUatAuthorizationContentSha256",
|
|
"leaveWorkflowUatAuthorizationIdSha256",
|
|
"diagnostics", "artifacts", "issuedAtUtc",
|
|
"expiresAtUtc", "validatedBy", "note");
|
|
if (RequiredString(content, "packageType", 1, 64)
|
|
!= "customer_commercial_acceptance_bundle")
|
|
throw Error("customer_acceptance_bundle_schema_invalid", "总验收包内容类型无效。");
|
|
string canonical = content.ToString(Formatting.None);
|
|
if (!string.Equals(contentHash, Sha256(Encoding.UTF8.GetBytes(canonical)),
|
|
StringComparison.Ordinal))
|
|
throw Error("customer_acceptance_bundle_hash_mismatch", "总验收包内容哈希不一致。");
|
|
if (!signatureVerifier.Verify(thumbprint, Encoding.UTF8.GetBytes(canonical), signature))
|
|
throw Error("customer_acceptance_bundle_signature_invalid", "总验收包 RSA 签名未通过 TrustedPeople 验证。");
|
|
|
|
string sourceCommit = RequiredString(content, "sourceCommit", 40, 40)
|
|
.ToLowerInvariant();
|
|
string packageHash = RequiredHash(content, "packageSha256");
|
|
string runtimeHash = RequiredHash(content, "runtimeConfigurationSha256");
|
|
string customerProfileHash = RequiredHash(
|
|
content,
|
|
"customerProfileSha256");
|
|
string rolloutHash = RequiredHash(content, "rolloutPolicySha256");
|
|
string rolloutCustomerId = RequiredString(
|
|
content, "rolloutCustomerId", 1, 64);
|
|
string runtimeCliVersion = RequiredString(
|
|
content, "runtimeCliVersion", 5, 14);
|
|
string runtimeCliHash = RequiredHash(content, "runtimeCliSha256");
|
|
string verifierCliHash = RequiredHash(content, "verifierCliSha256");
|
|
string purchaseUatSourceHash = RequiredHash(
|
|
content,
|
|
"purchaseWorkflowUatAuthorizationSourceSha256");
|
|
string purchaseUatContentHash = RequiredHash(
|
|
content,
|
|
"purchaseWorkflowUatAuthorizationContentSha256");
|
|
string purchaseUatAuthorizationIdHash = RequiredHash(
|
|
content,
|
|
"purchaseWorkflowUatAuthorizationIdSha256");
|
|
string leaveUatSourceHash = RequiredHash(
|
|
content,
|
|
"leaveWorkflowUatAuthorizationSourceSha256");
|
|
string leaveUatContentHash = RequiredHash(
|
|
content,
|
|
"leaveWorkflowUatAuthorizationContentSha256");
|
|
string leaveUatAuthorizationIdHash = RequiredHash(
|
|
content,
|
|
"leaveWorkflowUatAuthorizationIdSha256");
|
|
string miniMaxProbeHash = RequiredHash(
|
|
content, "miniMaxVisionProbeEvidenceSha256");
|
|
string miniMaxProbeObservedAtText = RequiredString(
|
|
content, "miniMaxVisionProbeObservedAtUtc", 20, 64);
|
|
DateTime miniMaxProbeObservedAt = RequiredProbeUtc(
|
|
miniMaxProbeObservedAtText, "miniMaxVisionProbeObservedAtUtc");
|
|
string miniMaxProbeRegion = RequiredString(
|
|
content, "miniMaxVisionProbeRegion", 2, 16);
|
|
string miniMaxProbeContractVersion = RequiredString(
|
|
content, "miniMaxVisionProbeContractVersion", 1, 16);
|
|
string verifierSignerThumbprint =
|
|
WindowsTrustedPeopleSignatureVerifier.NormalizeThumbprint(
|
|
RequiredString(content, "verifierSignerThumbprint", 40, 64));
|
|
string runtimeCliSignerThumbprint =
|
|
WindowsTrustedPeopleSignatureVerifier.NormalizeThumbprint(
|
|
RequiredString(
|
|
content,
|
|
"runtimeCliSignerThumbprint",
|
|
40,
|
|
64));
|
|
if (!CommitPattern.IsMatch(sourceCommit)
|
|
|| !SafeProductVersion.IsMatch(runtimeCliVersion))
|
|
throw Error("customer_acceptance_bundle_schema_invalid", "总验收包源码提交无效。");
|
|
if (!SafeIdentifier.IsMatch(rolloutCustomerId))
|
|
throw Error("customer_acceptance_bundle_schema_invalid", "总验收包客户部署标识无效。");
|
|
if (verifierSignerThumbprint == null
|
|
|| runtimeCliSignerThumbprint == null)
|
|
throw Error("customer_acceptance_bundle_schema_invalid", "总验收包验证器签名指纹无效。");
|
|
RequireExpected(sourceCommit, expectedSourceCommit, "源码提交");
|
|
RequireExpected(packageHash, expectedPackageSha256, "商用 ZIP SHA-256");
|
|
JObject scope = RequiredObject(content, "erpScope");
|
|
EnsureExact(
|
|
scope,
|
|
"accountBook",
|
|
"databaseScopeFingerprint");
|
|
string accountBook = RequiredString(scope, "accountBook", 1, 128);
|
|
string databaseScopeFingerprint = RequiredHash(
|
|
scope,
|
|
"databaseScopeFingerprint");
|
|
RequireExpected(accountBook, expectedAccountBook, "账套");
|
|
RequireExpected(
|
|
databaseScopeFingerprint,
|
|
expectedDatabaseScopeFingerprint,
|
|
"数据库作用域指纹");
|
|
JObject sessionScopes = RequiredObject(content, "sessionScopes");
|
|
EnsureExact(sessionScopes, "purchase", "leave", "diagnosis");
|
|
SessionScopeSummary purchaseSessionScope = ParseSessionScope(
|
|
RequiredObject(sessionScopes, "purchase"),
|
|
expectedPurchaseSubSystemId,
|
|
"采购");
|
|
SessionScopeSummary leaveSessionScope = ParseSessionScope(
|
|
RequiredObject(sessionScopes, "leave"),
|
|
expectedLeaveSubSystemId,
|
|
"请假");
|
|
SessionScopeSummary diagnosisSessionScope = ParseSessionScope(
|
|
RequiredObject(sessionScopes, "diagnosis"),
|
|
expectedDiagnosisSubSystemId,
|
|
"诊断");
|
|
DateTime preflightGeneratedAt = RequiredUtc(
|
|
content, "commercialPreflightGeneratedAtUtc");
|
|
DateTime issuedAt = RequiredUtc(content, "issuedAtUtc");
|
|
DateTime expiresAt = RequiredUtc(content, "expiresAtUtc");
|
|
if (issuedAt > nowUtc.AddMinutes(5) || issuedAt < nowUtc.AddDays(-7)
|
|
|| expiresAt <= nowUtc || expiresAt <= issuedAt
|
|
|| expiresAt - issuedAt > TimeSpan.FromDays(366)
|
|
|| preflightGeneratedAt > issuedAt.AddMinutes(5)
|
|
|| preflightGeneratedAt < issuedAt.AddDays(-7))
|
|
throw Error("customer_acceptance_bundle_expired", "总验收包或商用预检报告时间无效。");
|
|
ValidateSessionScopeTime(purchaseSessionScope, issuedAt);
|
|
ValidateSessionScopeTime(leaveSessionScope, issuedAt);
|
|
ValidateSessionScopeTime(diagnosisSessionScope, issuedAt);
|
|
string validatedBy = RequiredString(content, "validatedBy", 1, 128);
|
|
if (!SafeIdentifier.IsMatch(validatedBy))
|
|
throw Error("customer_acceptance_bundle_schema_invalid", "总验收包验证人标识无效。");
|
|
RequiredString(content, "note", 1, 500);
|
|
|
|
JObject artifactObject = RequiredObject(content, "artifacts");
|
|
EnsureExact(artifactObject, ArtifactNames);
|
|
Dictionary<string, ArtifactFile> artifacts = ResolveArtifacts(
|
|
artifactObject, rootPath);
|
|
AssertHash(artifacts["package"], packageHash, "商用 ZIP");
|
|
AssertHash(artifacts["runtimeConfiguration"], runtimeHash, "运行时配置");
|
|
AssertHash(artifacts["customerProfile"], customerProfileHash, "客户只读画像");
|
|
AssertHash(artifacts["rolloutPolicy"], rolloutHash, "命令发布策略");
|
|
AssertHash(artifacts["miniMaxVisionProbe"], miniMaxProbeHash,
|
|
"MiniMax 在线视觉探针");
|
|
|
|
PreflightVerificationState preflight = VerifyPreflight(
|
|
artifacts["commercialPreflight"].Path,
|
|
sourceCommit,
|
|
preflightGeneratedAt,
|
|
issuedAt);
|
|
if (!string.Equals(
|
|
preflight.PackageSha256,
|
|
packageHash,
|
|
StringComparison.Ordinal))
|
|
{
|
|
throw Error(
|
|
"customer_acceptance_bundle_package_binding_invalid",
|
|
"商用预检没有绑定总验收包中的同一个最终 ZIP。");
|
|
}
|
|
if (!string.Equals(
|
|
preflight.RolloutPolicySha256,
|
|
rolloutHash,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
preflight.RolloutCustomerId,
|
|
rolloutCustomerId,
|
|
StringComparison.Ordinal))
|
|
{
|
|
throw Error(
|
|
"customer_acceptance_bundle_rollout_invalid",
|
|
"商用预检与总验收包的命令发布策略不一致。");
|
|
}
|
|
if (!string.Equals(preflight.MiniMaxVisionProbeEvidenceSha256,
|
|
miniMaxProbeHash, StringComparison.Ordinal)
|
|
|| preflight.MiniMaxVisionProbeObservedAtUtc
|
|
!= miniMaxProbeObservedAt
|
|
|| !string.Equals(preflight.MiniMaxVisionProbeRegion,
|
|
miniMaxProbeRegion, StringComparison.Ordinal)
|
|
|| !string.Equals(preflight.MiniMaxVisionProbeContractVersion,
|
|
miniMaxProbeContractVersion, StringComparison.Ordinal))
|
|
{
|
|
throw Error(
|
|
"customer_acceptance_bundle_minimax_probe_invalid",
|
|
"商用预检与总验收包的 MiniMax 在线视觉探针不一致。");
|
|
}
|
|
MiniMaxVisionProbeState miniMaxProbe = VerifyMiniMaxVisionProbe(
|
|
artifacts["miniMaxVisionProbe"].Path,
|
|
preflightGeneratedAt,
|
|
issuedAt);
|
|
if (miniMaxProbe.ObservedAtUtc != miniMaxProbeObservedAt
|
|
|| !string.Equals(miniMaxProbe.Region, miniMaxProbeRegion,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(miniMaxProbe.ContractVersion,
|
|
miniMaxProbeContractVersion, StringComparison.Ordinal))
|
|
{
|
|
throw Error(
|
|
"customer_acceptance_bundle_minimax_probe_invalid",
|
|
"MiniMax 在线视觉探针原始报告与签名总验收内容不一致。");
|
|
}
|
|
try
|
|
{
|
|
CommandRolloutPolicy rollout = CommandRolloutPolicy.Load(
|
|
artifacts["rolloutPolicy"].Path,
|
|
rolloutHash,
|
|
rolloutCustomerId);
|
|
if (!rollout.Configured
|
|
|| !string.Equals(
|
|
rollout.DefaultAction,
|
|
"deny",
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
rollout.DatabaseScopeFingerprint,
|
|
databaseScopeFingerprint,
|
|
StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidOperationException();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
throw Error(
|
|
"customer_acceptance_bundle_rollout_invalid",
|
|
"总验收包命令发布策略未通过严格离线校验。");
|
|
}
|
|
AssertHash(
|
|
artifacts["legacyBuildEvidence"],
|
|
preflight.LegacyBuildEvidenceSha256,
|
|
"旧 ERP 构建证据");
|
|
LegacyBuildVerificationState legacy = VerifyLegacyBuildEvidence(
|
|
artifacts["legacyBuildEvidence"].Path, sourceCommit);
|
|
if (!string.Equals(
|
|
legacy.CliSha256,
|
|
verifierCliHash,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
legacy.CertificateThumbprint,
|
|
verifierSignerThumbprint,
|
|
StringComparison.Ordinal))
|
|
{
|
|
throw Error(
|
|
"customer_acceptance_bundle_verifier_invalid",
|
|
"总验收包声明的验证 CLI 与旧 ERP 构建证据不一致。");
|
|
}
|
|
|
|
JObject workflows = RequiredObject(content, "workflows");
|
|
EnsureExact(workflows, "purchase", "leave");
|
|
WorkflowSummary purchaseSummary = ParseWorkflowSummary(
|
|
RequiredObject(workflows, "purchase"), "purchase");
|
|
WorkflowSummary leaveSummary = ParseWorkflowSummary(
|
|
RequiredObject(workflows, "leave"), "leave");
|
|
WorkflowUatAuthorizationEvidence purchaseUat =
|
|
VerifyWorkflowUatAuthorization(
|
|
artifacts["purchaseWorkflowUatAuthorization"],
|
|
"purchase",
|
|
purchaseSummary.ModuleCode,
|
|
purchaseUatSourceHash,
|
|
purchaseUatContentHash,
|
|
purchaseUatAuthorizationIdHash,
|
|
rolloutCustomerId,
|
|
accountBook,
|
|
purchaseSessionScope.SubSystemId,
|
|
databaseScopeFingerprint,
|
|
runtimeHash,
|
|
customerProfileHash,
|
|
rolloutHash,
|
|
sourceCommit,
|
|
packageHash,
|
|
runtimeCliVersion,
|
|
runtimeCliHash,
|
|
runtimeCliSignerThumbprint,
|
|
verifierCliHash,
|
|
verifierSignerThumbprint,
|
|
legacy,
|
|
issuedAt,
|
|
signatureVerifier);
|
|
WorkflowUatAuthorizationEvidence leaveUat =
|
|
VerifyWorkflowUatAuthorization(
|
|
artifacts["leaveWorkflowUatAuthorization"],
|
|
"leave",
|
|
leaveSummary.ModuleCode,
|
|
leaveUatSourceHash,
|
|
leaveUatContentHash,
|
|
leaveUatAuthorizationIdHash,
|
|
rolloutCustomerId,
|
|
accountBook,
|
|
leaveSessionScope.SubSystemId,
|
|
databaseScopeFingerprint,
|
|
runtimeHash,
|
|
customerProfileHash,
|
|
rolloutHash,
|
|
sourceCommit,
|
|
packageHash,
|
|
runtimeCliVersion,
|
|
runtimeCliHash,
|
|
runtimeCliSignerThumbprint,
|
|
verifierCliHash,
|
|
verifierSignerThumbprint,
|
|
legacy,
|
|
issuedAt,
|
|
signatureVerifier);
|
|
AssertHash(
|
|
artifacts["purchaseWorkflowUatAuthorization"],
|
|
purchaseUatSourceHash,
|
|
"采购短时 UAT 授权");
|
|
AssertHash(
|
|
artifacts["leaveWorkflowUatAuthorization"],
|
|
leaveUatSourceHash,
|
|
"请假短时 UAT 授权");
|
|
|
|
JObject diagnostics = RequiredObject(content, "diagnostics");
|
|
EnsureExact(diagnostics,
|
|
"missingField", "permissionDenied", "invalidRelation",
|
|
"initializationSqlFailure");
|
|
DiagnosticSummary missingSummary = ParseDiagnosticSummary(
|
|
RequiredObject(diagnostics, "missingField"),
|
|
"missing_field");
|
|
DiagnosticSummary permissionSummary = ParseDiagnosticSummary(
|
|
RequiredObject(diagnostics, "permissionDenied"),
|
|
"permission_denied");
|
|
DiagnosticSummary relationSummary = ParseDiagnosticSummary(
|
|
RequiredObject(diagnostics, "invalidRelation"),
|
|
"invalid_relation");
|
|
DiagnosticSummary sqlSummary = ParseDiagnosticSummary(
|
|
RequiredObject(diagnostics, "initializationSqlFailure"),
|
|
"initialization_sql_failure");
|
|
DiagnosticVerificationState diagnosticState = new DiagnosticVerificationState();
|
|
VerifyDiagnostic(
|
|
missingSummary,
|
|
artifacts["diagnosticMissingField"], accountBook,
|
|
diagnosisSessionScope.SubSystemId,
|
|
issuedAt, diagnosticState);
|
|
VerifyDiagnostic(
|
|
permissionSummary,
|
|
artifacts["diagnosticPermissionDenied"], accountBook,
|
|
diagnosisSessionScope.SubSystemId,
|
|
issuedAt, diagnosticState);
|
|
VerifyDiagnostic(
|
|
relationSummary,
|
|
artifacts["diagnosticInvalidRelation"], accountBook,
|
|
diagnosisSessionScope.SubSystemId,
|
|
issuedAt, diagnosticState);
|
|
VerifyDiagnostic(
|
|
sqlSummary,
|
|
artifacts["diagnosticInitializationSqlFailure"], accountBook,
|
|
diagnosisSessionScope.SubSystemId, issuedAt, diagnosticState);
|
|
if (diagnosticState.EvidenceIds.Count != 4
|
|
|| diagnosticState.ContentHashes.Count != 4
|
|
|| diagnosticState.UserIds.Count != 1
|
|
|| diagnosticState.UserNames.Count != 1
|
|
|| diagnosticState.DatabaseScopeFingerprints.Count != 1
|
|
|| !string.Equals(
|
|
diagnosticState.DatabaseScopeFingerprints.Single(),
|
|
databaseScopeFingerprint,
|
|
StringComparison.Ordinal)
|
|
|| !AdministratorIdentity.IsBuiltIn(
|
|
diagnosticState.UserIds.Single(),
|
|
diagnosticState.UserNames.Single()))
|
|
throw Error(
|
|
"customer_acceptance_bundle_diagnostic_invalid",
|
|
"四类诊断必须使用同一管理员并保留四份不同的原始证据。");
|
|
|
|
ReadOnlySessionPreflightState purchaseReadOnlySession =
|
|
VerifyReadOnlySessionPreflight(
|
|
artifacts["purchaseReadOnlySessionPreflight"].Path,
|
|
purchaseSessionScope.ReadOnlySessionPreflightGeneratedAtUtc,
|
|
issuedAt,
|
|
databaseScopeFingerprint,
|
|
accountBook,
|
|
purchaseSessionScope.SubSystemId,
|
|
purchaseUat.UserId,
|
|
purchaseUat.UserName,
|
|
legacy.ErpSha256,
|
|
legacy.CertificateThumbprint,
|
|
runtimeCliVersion,
|
|
runtimeCliHash,
|
|
runtimeCliSignerThumbprint,
|
|
rolloutHash,
|
|
rolloutCustomerId,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
new[] { purchaseSummary.ModuleCode });
|
|
FieldReadOnlyValidationHandoffState purchaseHandoff =
|
|
VerifyFieldReadOnlyValidationHandoff(
|
|
artifacts["purchaseFieldReadOnlyValidationHandoff"].Path,
|
|
purchaseSessionScope.FieldReadOnlyValidationHandoffGeneratedAtUtc,
|
|
issuedAt,
|
|
artifacts["purchaseReadOnlySessionPreflight"].Sha256,
|
|
purchaseReadOnlySession,
|
|
"purchase",
|
|
1);
|
|
ReadOnlySessionPreflightState leaveReadOnlySession =
|
|
VerifyReadOnlySessionPreflight(
|
|
artifacts["leaveReadOnlySessionPreflight"].Path,
|
|
leaveSessionScope.ReadOnlySessionPreflightGeneratedAtUtc,
|
|
issuedAt,
|
|
databaseScopeFingerprint,
|
|
accountBook,
|
|
leaveSessionScope.SubSystemId,
|
|
leaveUat.UserId,
|
|
leaveUat.UserName,
|
|
legacy.ErpSha256,
|
|
legacy.CertificateThumbprint,
|
|
runtimeCliVersion,
|
|
runtimeCliHash,
|
|
runtimeCliSignerThumbprint,
|
|
rolloutHash,
|
|
rolloutCustomerId,
|
|
false,
|
|
true,
|
|
false,
|
|
false,
|
|
new[] { leaveSummary.ModuleCode });
|
|
FieldReadOnlyValidationHandoffState leaveHandoff =
|
|
VerifyFieldReadOnlyValidationHandoff(
|
|
artifacts["leaveFieldReadOnlyValidationHandoff"].Path,
|
|
leaveSessionScope.FieldReadOnlyValidationHandoffGeneratedAtUtc,
|
|
issuedAt,
|
|
artifacts["leaveReadOnlySessionPreflight"].Sha256,
|
|
leaveReadOnlySession,
|
|
"leave",
|
|
1);
|
|
ReadOnlySessionPreflightState diagnosisReadOnlySession =
|
|
VerifyReadOnlySessionPreflight(
|
|
artifacts["diagnosisReadOnlySessionPreflight"].Path,
|
|
diagnosisSessionScope.ReadOnlySessionPreflightGeneratedAtUtc,
|
|
issuedAt,
|
|
databaseScopeFingerprint,
|
|
accountBook,
|
|
diagnosisSessionScope.SubSystemId,
|
|
diagnosticState.UserIds.Single(),
|
|
diagnosticState.UserNames.Single(),
|
|
legacy.ErpSha256,
|
|
legacy.CertificateThumbprint,
|
|
runtimeCliVersion,
|
|
runtimeCliHash,
|
|
runtimeCliSignerThumbprint,
|
|
rolloutHash,
|
|
rolloutCustomerId,
|
|
false,
|
|
false,
|
|
true,
|
|
true,
|
|
diagnosticState.ModuleCodes);
|
|
FieldReadOnlyValidationHandoffState diagnosisHandoff =
|
|
VerifyFieldReadOnlyValidationHandoff(
|
|
artifacts["diagnosisFieldReadOnlyValidationHandoff"].Path,
|
|
diagnosisSessionScope.FieldReadOnlyValidationHandoffGeneratedAtUtc,
|
|
issuedAt,
|
|
artifacts["diagnosisReadOnlySessionPreflight"].Sha256,
|
|
diagnosisReadOnlySession,
|
|
"diagnosis",
|
|
diagnosticState.ModuleCodes.Count);
|
|
|
|
VerifyWorkflow(
|
|
purchaseSummary, "purchase", artifacts["purchaseAcceptance"],
|
|
artifacts["purchaseWriteIntegration"], accountBook,
|
|
purchaseSessionScope.SubSystemId,
|
|
runtimeHash, customerProfileHash, sourceCommit, packageHash,
|
|
purchaseUat, signatureVerifier, nowUtc);
|
|
VerifyWorkflow(
|
|
leaveSummary, "leave", artifacts["leaveAcceptance"],
|
|
artifacts["leaveWriteIntegration"], accountBook,
|
|
leaveSessionScope.SubSystemId,
|
|
runtimeHash, customerProfileHash, sourceCommit, packageHash,
|
|
leaveUat, signatureVerifier, nowUtc);
|
|
|
|
return new CustomerAcceptanceBundleVerificationResult
|
|
{
|
|
ContentSha256 = contentHash,
|
|
SourceCommit = sourceCommit,
|
|
PackageSha256 = packageHash,
|
|
RuntimeConfigurationSha256 = runtimeHash,
|
|
CustomerProfileSha256 = customerProfileHash,
|
|
RolloutPolicySha256 = rolloutHash,
|
|
RolloutCustomerId = rolloutCustomerId,
|
|
DatabaseScopeFingerprint = databaseScopeFingerprint,
|
|
RuntimeCliVersion = runtimeCliVersion,
|
|
RuntimeCliSha256 = runtimeCliHash,
|
|
RuntimeCliSignerThumbprint = runtimeCliSignerThumbprint,
|
|
VerifierCliSha256 = verifierCliHash,
|
|
VerifierSignerThumbprint = verifierSignerThumbprint,
|
|
PurchaseReadOnlySessionPreflightGeneratedAtUtc =
|
|
purchaseReadOnlySession.GeneratedAtUtcText,
|
|
PurchaseFieldReadOnlyValidationHandoffGeneratedAtUtc =
|
|
purchaseHandoff.GeneratedAtUtcText,
|
|
PurchaseReadOnlySessionErpProcessId =
|
|
purchaseReadOnlySession.ErpProcessId,
|
|
PurchaseReadOnlySessionErpSha256 = purchaseReadOnlySession.ErpSha256,
|
|
PurchaseReadOnlySessionErpSignerThumbprint =
|
|
purchaseReadOnlySession.ErpSignerThumbprint,
|
|
LeaveReadOnlySessionPreflightGeneratedAtUtc =
|
|
leaveReadOnlySession.GeneratedAtUtcText,
|
|
LeaveFieldReadOnlyValidationHandoffGeneratedAtUtc =
|
|
leaveHandoff.GeneratedAtUtcText,
|
|
LeaveReadOnlySessionErpProcessId = leaveReadOnlySession.ErpProcessId,
|
|
LeaveReadOnlySessionErpSha256 = leaveReadOnlySession.ErpSha256,
|
|
LeaveReadOnlySessionErpSignerThumbprint =
|
|
leaveReadOnlySession.ErpSignerThumbprint,
|
|
DiagnosisReadOnlySessionPreflightGeneratedAtUtc =
|
|
diagnosisReadOnlySession.GeneratedAtUtcText,
|
|
DiagnosisFieldReadOnlyValidationHandoffGeneratedAtUtc =
|
|
diagnosisHandoff.GeneratedAtUtcText,
|
|
DiagnosisReadOnlySessionErpProcessId =
|
|
diagnosisReadOnlySession.ErpProcessId,
|
|
DiagnosisReadOnlySessionErpSha256 = diagnosisReadOnlySession.ErpSha256,
|
|
DiagnosisReadOnlySessionErpSignerThumbprint =
|
|
diagnosisReadOnlySession.ErpSignerThumbprint,
|
|
PurchaseWorkflowUatAuthorizationSourceSha256 = purchaseUatSourceHash,
|
|
PurchaseWorkflowUatAuthorizationContentSha256 = purchaseUatContentHash,
|
|
PurchaseWorkflowUatAuthorizationIdSha256 =
|
|
purchaseUatAuthorizationIdHash,
|
|
LeaveWorkflowUatAuthorizationSourceSha256 = leaveUatSourceHash,
|
|
LeaveWorkflowUatAuthorizationContentSha256 = leaveUatContentHash,
|
|
LeaveWorkflowUatAuthorizationIdSha256 = leaveUatAuthorizationIdHash,
|
|
MiniMaxVisionProbeEvidenceSha256 = miniMaxProbeHash,
|
|
MiniMaxVisionProbeObservedAtUtc = miniMaxProbeObservedAtText,
|
|
MiniMaxVisionProbeRegion = miniMaxProbeRegion,
|
|
MiniMaxVisionProbeContractVersion = miniMaxProbeContractVersion,
|
|
AccountBook = accountBook,
|
|
PurchaseSubSystemId = purchaseSessionScope.SubSystemId,
|
|
LeaveSubSystemId = leaveSessionScope.SubSystemId,
|
|
DiagnosisSubSystemId = diagnosisSessionScope.SubSystemId,
|
|
PurchaseModuleCode = purchaseSummary.ModuleCode,
|
|
LeaveModuleCode = leaveSummary.ModuleCode,
|
|
IssuedAtUtc = issuedAt,
|
|
ExpiresAtUtc = expiresAt,
|
|
ValidatedBy = validatedBy,
|
|
CertificateThumbprint = normalizedThumbprint
|
|
};
|
|
}
|
|
|
|
private static SessionScopeSummary ParseSessionScope(
|
|
JObject value,
|
|
string expectedSubSystemId,
|
|
string label)
|
|
{
|
|
EnsureExact(
|
|
value,
|
|
"subSystemId",
|
|
"readOnlySessionPreflightGeneratedAtUtc",
|
|
"fieldReadOnlyValidationHandoffGeneratedAtUtc");
|
|
string subSystemId = RequiredString(value, "subSystemId", 1, 128);
|
|
RequireExpected(subSystemId, expectedSubSystemId, label + "子系统");
|
|
return new SessionScopeSummary
|
|
{
|
|
SubSystemId = subSystemId,
|
|
ReadOnlySessionPreflightGeneratedAtUtc = RequiredUtc(
|
|
value,
|
|
"readOnlySessionPreflightGeneratedAtUtc"),
|
|
FieldReadOnlyValidationHandoffGeneratedAtUtc = RequiredUtc(
|
|
value,
|
|
"fieldReadOnlyValidationHandoffGeneratedAtUtc")
|
|
};
|
|
}
|
|
|
|
private static void ValidateSessionScopeTime(
|
|
SessionScopeSummary scope,
|
|
DateTime issuedAtUtc)
|
|
{
|
|
if (scope.ReadOnlySessionPreflightGeneratedAtUtc
|
|
> issuedAtUtc.AddMinutes(5)
|
|
|| scope.ReadOnlySessionPreflightGeneratedAtUtc
|
|
< issuedAtUtc.AddDays(-7)
|
|
|| scope.FieldReadOnlyValidationHandoffGeneratedAtUtc
|
|
> issuedAtUtc.AddMinutes(5)
|
|
|| scope.FieldReadOnlyValidationHandoffGeneratedAtUtc
|
|
< issuedAtUtc.AddDays(-7))
|
|
throw Error(
|
|
"customer_acceptance_bundle_expired",
|
|
"总验收包中的独立 ERP 会话证据时间无效。");
|
|
}
|
|
|
|
private static WorkflowUatAuthorizationEvidence
|
|
VerifyWorkflowUatAuthorization(
|
|
ArtifactFile artifact,
|
|
string workflow,
|
|
string moduleCode,
|
|
string sourceHash,
|
|
string contentHash,
|
|
string authorizationIdHash,
|
|
string rolloutCustomerId,
|
|
string accountBook,
|
|
string subSystemId,
|
|
string databaseScopeFingerprint,
|
|
string runtimeHash,
|
|
string customerProfileHash,
|
|
string rolloutHash,
|
|
string sourceCommit,
|
|
string packageHash,
|
|
string runtimeCliVersion,
|
|
string runtimeCliHash,
|
|
string runtimeCliSignerThumbprint,
|
|
string verifierCliHash,
|
|
string verifierSignerThumbprint,
|
|
LegacyBuildVerificationState legacy,
|
|
DateTime issuedAt,
|
|
IAcceptanceSignatureVerifier signatureVerifier)
|
|
{
|
|
WorkflowUatAuthorizationEvidence uat;
|
|
try
|
|
{
|
|
uat = WorkflowUatAuthorizationVerifier.VerifyHistoricalFile(
|
|
artifact.Path,
|
|
signatureVerifier);
|
|
}
|
|
catch (CommandKernelException)
|
|
{
|
|
throw Error(
|
|
"customer_acceptance_bundle_uat_invalid",
|
|
"总验收包中的独立短时 UAT 授权未通过历史签名验证。");
|
|
}
|
|
string calculatedAuthorizationIdHash =
|
|
WorkflowUatAuthorizationVerifier.Sha256(
|
|
Encoding.UTF8.GetBytes(uat.AuthorizationId));
|
|
WorkflowUatWorkflowAuthorization workflowAuthorization;
|
|
if (!string.Equals(uat.SourceSha256, sourceHash,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(uat.ContentSha256, contentHash,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(calculatedAuthorizationIdHash,
|
|
authorizationIdHash, StringComparison.Ordinal)
|
|
|| !string.Equals(uat.CustomerId, rolloutCustomerId,
|
|
StringComparison.Ordinal)
|
|
|| !Same(uat.AccountBook, accountBook)
|
|
|| !Same(uat.SubSystemId, subSystemId)
|
|
|| !string.Equals(
|
|
uat.DatabaseScopeFingerprint,
|
|
databaseScopeFingerprint,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(uat.RuntimeConfigurationSha256, runtimeHash,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(uat.CustomerProfileSha256,
|
|
customerProfileHash, StringComparison.Ordinal)
|
|
|| !string.Equals(uat.RolloutPolicySha256, rolloutHash,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(uat.SourceCommit, sourceCommit,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(uat.PackageSha256, packageHash,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
uat.RuntimeCli.Version,
|
|
runtimeCliVersion,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
uat.RuntimeCli.Sha256,
|
|
runtimeCliHash,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
uat.RuntimeCli.SignerThumbprint,
|
|
runtimeCliSignerThumbprint,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(uat.VerifierCli.Sha256, verifierCliHash,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(uat.VerifierCli.SignerThumbprint,
|
|
verifierSignerThumbprint, StringComparison.Ordinal)
|
|
|| !string.Equals(uat.ErpExecutable.Sha256,
|
|
legacy.ErpSha256, StringComparison.Ordinal)
|
|
|| !string.Equals(uat.ErpExecutable.SignerThumbprint,
|
|
legacy.CertificateThumbprint, StringComparison.Ordinal)
|
|
|| issuedAt < uat.IssuedAtUtc.AddMinutes(-5)
|
|
|| issuedAt > uat.ExpiresAtUtc.AddMinutes(5)
|
|
|| uat.Workflows.Count != 1
|
|
|| !uat.Workflows.TryGetValue(
|
|
workflow,
|
|
out workflowAuthorization)
|
|
|| !string.Equals(
|
|
workflowAuthorization.ModuleCode,
|
|
moduleCode,
|
|
StringComparison.Ordinal))
|
|
{
|
|
throw Error(
|
|
"customer_acceptance_bundle_uat_mismatch",
|
|
"独立短时 UAT 授权与对应工作流、子系统、ERP/CLI 或发布范围不一致。");
|
|
}
|
|
return uat;
|
|
}
|
|
|
|
private static void VerifyWorkflow(
|
|
WorkflowSummary summary,
|
|
string workflow,
|
|
ArtifactFile acceptanceArtifact,
|
|
ArtifactFile writeArtifact,
|
|
string accountBook,
|
|
string subSystemId,
|
|
string runtimeHash,
|
|
string customerProfileHash,
|
|
string sourceCommit,
|
|
string packageHash,
|
|
WorkflowUatAuthorizationEvidence uat,
|
|
IAcceptanceSignatureVerifier signatureVerifier,
|
|
DateTime nowUtc)
|
|
{
|
|
BusinessAcceptanceEvidence acceptance =
|
|
BusinessAcceptanceEvidenceVerifier.VerifyFile(
|
|
acceptanceArtifact.Path, signatureVerifier, nowUtc);
|
|
WorkflowWriteIntegrationEvidenceResult write =
|
|
WorkflowWriteIntegrationEvidenceVerifier.VerifyFile(
|
|
writeArtifact.Path, workflow, summary.ModuleCode, accountBook,
|
|
subSystemId, runtimeHash, sourceCommit, packageHash, nowUtc);
|
|
WorkflowUatWorkflowAuthorization uatWorkflow;
|
|
if (!Same(acceptance.Workflow, workflow)
|
|
|| !Same(acceptance.ModuleCode, summary.ModuleCode)
|
|
|| !Same(acceptance.AccountBook, accountBook)
|
|
|| !Same(acceptance.SubSystemId, subSystemId)
|
|
|| !string.Equals(acceptance.RuntimeConfigurationSha256, runtimeHash,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(acceptance.CustomerProfileSha256,
|
|
customerProfileHash, StringComparison.Ordinal)
|
|
|| !string.Equals(acceptance.ContentSha256,
|
|
summary.AcceptanceContentSha256, StringComparison.Ordinal)
|
|
|| !string.Equals(acceptance.EvidenceId,
|
|
summary.AcceptanceEvidenceId, StringComparison.Ordinal)
|
|
|| !string.Equals(acceptance.WriteIntegrationEvidenceSha256,
|
|
writeArtifact.Sha256, StringComparison.Ordinal)
|
|
|| !string.Equals(write.ContentSha256,
|
|
summary.WriteIntegrationContentSha256, StringComparison.Ordinal)
|
|
|| !string.Equals(write.EnvironmentId, summary.EnvironmentId,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(write.TestedBy, summary.TestedBy,
|
|
StringComparison.Ordinal)
|
|
|| uat == null
|
|
|| !uat.Workflows.TryGetValue(workflow, out uatWorkflow)
|
|
|| !string.Equals(
|
|
uatWorkflow.ModuleCode,
|
|
summary.ModuleCode,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
uat.EnvironmentId,
|
|
write.EnvironmentId,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
write.UserIdSha256,
|
|
WorkflowUatAuthorizationVerifier.Sha256(
|
|
Encoding.UTF8.GetBytes(uat.UserId)),
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
write.UserNameSha256,
|
|
WorkflowUatAuthorizationVerifier.Sha256(
|
|
Encoding.UTF8.GetBytes(uat.UserName)),
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
write.DatabaseScopeFingerprint,
|
|
uat.DatabaseScopeFingerprint,
|
|
StringComparison.Ordinal)
|
|
|| write.IsAdministrator != AdministratorIdentity.IsBuiltIn(
|
|
uat.UserId,
|
|
uat.UserName)
|
|
|| !string.Equals(
|
|
uat.SourceSha256,
|
|
write.UatAuthorizationSourceSha256,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
uat.ContentSha256,
|
|
write.UatAuthorizationContentSha256,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
WorkflowUatAuthorizationVerifier.Sha256(
|
|
Encoding.UTF8.GetBytes(uat.AuthorizationId)),
|
|
write.UatAuthorizationIdSha256,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
write.RuntimeCliVersion,
|
|
uat.RuntimeCli.Version,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
write.RuntimeCliSha256,
|
|
uat.RuntimeCli.Sha256,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
write.RuntimeCliSignerThumbprint,
|
|
uat.RuntimeCli.SignerThumbprint,
|
|
StringComparison.Ordinal)
|
|
|| write.TestedAtUtc < uat.IssuedAtUtc.AddMinutes(-5)
|
|
|| write.TestedAtUtc > uat.ExpiresAtUtc.AddMinutes(5)
|
|
|| write.UatTokenSha256ByCase == null
|
|
|| write.UatTokenSha256ByCase.Count != uatWorkflow.Cases.Count
|
|
|| uatWorkflow.Cases.Any(pair =>
|
|
!write.UatTokenSha256ByCase.ContainsKey(pair.Key)
|
|
|| !string.Equals(
|
|
write.UatTokenSha256ByCase[pair.Key],
|
|
pair.Value.TokenSha256,
|
|
StringComparison.Ordinal))
|
|
|| Math.Abs((write.TestedAtUtc - summary.TestedAtUtc).TotalSeconds) > 1
|
|
|| acceptance.IssuedAtUtc < write.TestedAtUtc.AddMinutes(-5))
|
|
throw Error(
|
|
"customer_acceptance_bundle_workflow_mismatch",
|
|
"工作流清单、写集成证据或总包摘要不一致。");
|
|
}
|
|
|
|
private static WorkflowSummary ParseWorkflowSummary(JObject value, string workflow)
|
|
{
|
|
EnsureExact(value,
|
|
"workflow", "moduleCode", "acceptanceEvidenceId",
|
|
"acceptanceContentSha256", "writeIntegrationContentSha256",
|
|
"environmentId", "testedAtUtc", "testedBy");
|
|
if (RequiredString(value, "workflow", 1, 32) != workflow)
|
|
throw Error("customer_acceptance_bundle_schema_invalid", "工作流摘要类型无效。");
|
|
string moduleCode = RequiredString(value, "moduleCode", 1, 64);
|
|
string environmentId = RequiredString(value, "environmentId", 8, 128);
|
|
string testedBy = RequiredString(value, "testedBy", 1, 128);
|
|
if (!SafeModule.IsMatch(moduleCode) || !SafeIdentifier.IsMatch(environmentId)
|
|
|| !SafeIdentifier.IsMatch(testedBy))
|
|
throw Error("customer_acceptance_bundle_schema_invalid", "工作流摘要标识无效。");
|
|
return new WorkflowSummary
|
|
{
|
|
ModuleCode = moduleCode,
|
|
AcceptanceEvidenceId = RequiredString(
|
|
value, "acceptanceEvidenceId", 8, 128),
|
|
AcceptanceContentSha256 = RequiredHash(
|
|
value, "acceptanceContentSha256"),
|
|
WriteIntegrationContentSha256 = RequiredHash(
|
|
value, "writeIntegrationContentSha256"),
|
|
EnvironmentId = environmentId,
|
|
TestedAtUtc = RequiredUtc(value, "testedAtUtc"),
|
|
TestedBy = testedBy
|
|
};
|
|
}
|
|
|
|
private static DiagnosticSummary ParseDiagnosticSummary(
|
|
JObject value,
|
|
string expectedScenario)
|
|
{
|
|
EnsureExact(value,
|
|
"scenarioCode", "evidenceId", "contentSha256", "moduleCode",
|
|
"primaryFindingCode", "capturedAtUtc");
|
|
string scenario = RequiredString(value, "scenarioCode", 1, 64);
|
|
string moduleCode = RequiredString(value, "moduleCode", 1, 64);
|
|
if (scenario != expectedScenario || !SafeModule.IsMatch(moduleCode))
|
|
throw Error("customer_acceptance_bundle_schema_invalid", "诊断场景摘要无效。");
|
|
return new DiagnosticSummary
|
|
{
|
|
ScenarioCode = scenario,
|
|
EvidenceId = RequiredString(value, "evidenceId", 8, 64),
|
|
ContentSha256 = RequiredHash(value, "contentSha256"),
|
|
ModuleCode = moduleCode,
|
|
PrimaryFindingCode = RequiredString(value, "primaryFindingCode", 1, 128),
|
|
CapturedAtUtc = RequiredUtc(value, "capturedAtUtc")
|
|
};
|
|
}
|
|
|
|
private static void VerifyDiagnostic(
|
|
DiagnosticSummary summary,
|
|
ArtifactFile artifact,
|
|
string accountBook,
|
|
string subSystemId,
|
|
DateTime issuedAtUtc,
|
|
DiagnosticVerificationState state)
|
|
{
|
|
ModuleDiagnosticEvidenceVerificationResult value;
|
|
try
|
|
{
|
|
value = ModuleDiagnosticEvidenceVerifier.VerifyFile(artifact.Path);
|
|
}
|
|
catch (ModuleDiagnosticEvidenceException)
|
|
{
|
|
throw Error(
|
|
"customer_acceptance_bundle_diagnostic_invalid",
|
|
"总验收包中的诊断原始证据未通过严格验证。");
|
|
}
|
|
if (!Same(value.AccountBook, accountBook)
|
|
|| !Same(value.SubSystemId, subSystemId)
|
|
|| !Same(value.ModuleCode, summary.ModuleCode)
|
|
|| !string.Equals(value.EvidenceId, summary.EvidenceId,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(value.ContentHash, summary.ContentSha256,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(value.PrimaryFindingCode,
|
|
summary.PrimaryFindingCode, StringComparison.Ordinal)
|
|
|| Math.Abs((value.CapturedAtUtc - summary.CapturedAtUtc).TotalSeconds) > 1
|
|
|| value.Outcome != "failed" || value.ModuleOpenSucceeded
|
|
|| value.Truncated || value.CapturedAtUtc > issuedAtUtc.AddMinutes(5)
|
|
|| value.CapturedAtUtc < issuedAtUtc.AddDays(-30))
|
|
throw Error(
|
|
"customer_acceptance_bundle_diagnostic_mismatch",
|
|
"诊断证据与总包场景摘要、作用域或时间窗口不一致。");
|
|
if (summary.ScenarioCode == "missing_field"
|
|
&& value.PrimaryFindingCode != "missing_column"
|
|
&& value.PrimaryFindingCode != "missing_object")
|
|
throw Error("customer_acceptance_bundle_diagnostic_invalid", "缺字段场景没有观察到缺列或缺对象结论。");
|
|
if (summary.ScenarioCode == "permission_denied"
|
|
&& value.PrimaryFindingCode != "database_permission")
|
|
throw Error("customer_acceptance_bundle_diagnostic_invalid", "缺权限场景没有观察到数据库权限结论。");
|
|
if (summary.ScenarioCode == "invalid_relation"
|
|
&& !InvalidRelationCode(value.PrimaryFindingCode))
|
|
throw Error("customer_acceptance_bundle_diagnostic_invalid", "无效关联场景没有观察到可接受的配置或合同结论。");
|
|
if (summary.ScenarioCode == "initialization_sql_failure"
|
|
&& value.FailedEventCount < 1)
|
|
throw Error("customer_acceptance_bundle_diagnostic_invalid", "初始化 SQL 失败场景没有捕获失败 SQL 事件。");
|
|
if (!state.EvidenceIds.Add(value.EvidenceId)
|
|
|| !state.ContentHashes.Add(value.ContentHash)
|
|
|| string.IsNullOrWhiteSpace(value.UserId)
|
|
|| string.IsNullOrWhiteSpace(value.UserName)
|
|
|| string.IsNullOrWhiteSpace(value.DatabaseScopeFingerprint))
|
|
throw Error("customer_acceptance_bundle_diagnostic_invalid", "诊断证据重复或缺少管理员身份范围。");
|
|
state.UserIds.Add(value.UserId);
|
|
state.UserNames.Add(value.UserName);
|
|
state.DatabaseScopeFingerprints.Add(
|
|
value.DatabaseScopeFingerprint);
|
|
state.ModuleCodes.Add(value.ModuleCode);
|
|
}
|
|
|
|
private static bool InvalidRelationCode(string code)
|
|
{
|
|
return code == "procedure_parameter" || code == "conversion"
|
|
|| code == "constraint" || code == "missing_object"
|
|
|| code == "missing_column" || code == "module_initialization_error"
|
|
|| code == "unclassified_module_error";
|
|
}
|
|
|
|
private static ReadOnlySessionPreflightState
|
|
VerifyReadOnlySessionPreflight(
|
|
string path,
|
|
DateTime expectedGeneratedAtUtc,
|
|
DateTime issuedAtUtc,
|
|
string databaseScopeFingerprint,
|
|
string accountBook,
|
|
string subSystemId,
|
|
string userId,
|
|
string userName,
|
|
string erpSha256,
|
|
string erpSignerThumbprint,
|
|
string runtimeCliVersion,
|
|
string runtimeCliSha256,
|
|
string runtimeCliSignerThumbprint,
|
|
string rolloutPolicySha256,
|
|
string rolloutCustomerId,
|
|
bool requirePurchase,
|
|
bool requireLeave,
|
|
bool requireDiagnosis,
|
|
bool requireAdministrator,
|
|
IEnumerable<string> expectedModuleCodes)
|
|
{
|
|
try
|
|
{
|
|
return VerifyReadOnlySessionPreflightCore(
|
|
path,
|
|
expectedGeneratedAtUtc,
|
|
issuedAtUtc,
|
|
databaseScopeFingerprint,
|
|
accountBook,
|
|
subSystemId,
|
|
userId,
|
|
userName,
|
|
erpSha256,
|
|
erpSignerThumbprint,
|
|
runtimeCliVersion,
|
|
runtimeCliSha256,
|
|
runtimeCliSignerThumbprint,
|
|
rolloutPolicySha256,
|
|
rolloutCustomerId,
|
|
requirePurchase,
|
|
requireLeave,
|
|
requireDiagnosis,
|
|
requireAdministrator,
|
|
expectedModuleCodes);
|
|
}
|
|
catch (CommandKernelException error)
|
|
{
|
|
if (string.Equals(
|
|
error.Code,
|
|
"customer_acceptance_bundle_readonly_preflight_invalid",
|
|
StringComparison.Ordinal))
|
|
throw;
|
|
throw Error(
|
|
"customer_acceptance_bundle_readonly_preflight_invalid",
|
|
"指定 ERP 会话的只读动态合同预检报告无效或与最终验收范围不一致。");
|
|
}
|
|
catch
|
|
{
|
|
throw Error(
|
|
"customer_acceptance_bundle_readonly_preflight_invalid",
|
|
"指定 ERP 会话的只读动态合同预检报告无效或与最终验收范围不一致。");
|
|
}
|
|
}
|
|
|
|
private static ReadOnlySessionPreflightState
|
|
VerifyReadOnlySessionPreflightCore(
|
|
string path,
|
|
DateTime expectedGeneratedAtUtc,
|
|
DateTime issuedAtUtc,
|
|
string databaseScopeFingerprint,
|
|
string accountBook,
|
|
string subSystemId,
|
|
string userId,
|
|
string userName,
|
|
string erpSha256,
|
|
string erpSignerThumbprint,
|
|
string runtimeCliVersion,
|
|
string runtimeCliSha256,
|
|
string runtimeCliSignerThumbprint,
|
|
string rolloutPolicySha256,
|
|
string rolloutCustomerId,
|
|
bool requirePurchase,
|
|
bool requireLeave,
|
|
bool requireDiagnosis,
|
|
bool requireAdministrator,
|
|
IEnumerable<string> expectedModuleCodes)
|
|
{
|
|
HashSet<string> requiredModuleCodes = new HashSet<string>(
|
|
expectedModuleCodes ?? new string[0],
|
|
StringComparer.OrdinalIgnoreCase);
|
|
if (requiredModuleCodes.Count < 1
|
|
|| requiredModuleCodes.Count > 16
|
|
|| requiredModuleCodes.Any(item =>
|
|
string.IsNullOrWhiteSpace(item)
|
|
|| !SafeModule.IsMatch(item)))
|
|
throw ReadOnlyPreflightError();
|
|
JObject report = LoadStrict(
|
|
path,
|
|
2 * 1024 * 1024,
|
|
"ERP 只读会话预检报告");
|
|
EnsureExact(
|
|
report,
|
|
"schemaVersion",
|
|
"evidenceType",
|
|
"generatedAtUtc",
|
|
"passed",
|
|
"readOnlySessionReady",
|
|
"productionWriteAuthorized",
|
|
"erpProcessId",
|
|
"erpExecutable",
|
|
"cli",
|
|
"rolloutPolicy",
|
|
"session",
|
|
"bridge",
|
|
"requestedReadiness",
|
|
"capabilityReadiness",
|
|
"modules",
|
|
"readOnlyBoundary",
|
|
"checks",
|
|
"note");
|
|
DateTime generatedAt = RequiredUtc(report, "generatedAtUtc");
|
|
if (RequiredString(report, "schemaVersion", 1, 16) != "1.5"
|
|
|| RequiredString(report, "evidenceType", 1, 64)
|
|
!= "lserp_readonly_session_preflight"
|
|
|| !RequiredBoolean(report, "passed")
|
|
|| !RequiredBoolean(report, "readOnlySessionReady")
|
|
|| RequiredBoolean(report, "productionWriteAuthorized")
|
|
|| Math.Abs((generatedAt - expectedGeneratedAtUtc).TotalSeconds)
|
|
> 1
|
|
|| generatedAt > issuedAtUtc.AddMinutes(5)
|
|
|| generatedAt < issuedAtUtc.AddDays(-7))
|
|
throw ReadOnlyPreflightError();
|
|
int erpProcessId = checked((int)RequiredLong(
|
|
report,
|
|
"erpProcessId",
|
|
1,
|
|
int.MaxValue));
|
|
|
|
JObject erpExecutable = RequiredObject(report, "erpExecutable");
|
|
EnsureExact(erpExecutable, "sha256", "signerThumbprint");
|
|
string normalizedErpSigner = WindowsTrustedPeopleSignatureVerifier
|
|
.NormalizeThumbprint(RequiredString(
|
|
erpExecutable,
|
|
"signerThumbprint",
|
|
40,
|
|
64));
|
|
if (!string.Equals(
|
|
RequiredHash(erpExecutable, "sha256"),
|
|
erpSha256,
|
|
StringComparison.Ordinal)
|
|
|| normalizedErpSigner == null
|
|
|| !string.Equals(
|
|
normalizedErpSigner,
|
|
erpSignerThumbprint,
|
|
StringComparison.Ordinal))
|
|
throw ReadOnlyPreflightError();
|
|
|
|
JObject cli = RequiredObject(report, "cli");
|
|
EnsureExact(
|
|
cli,
|
|
"component",
|
|
"version",
|
|
"protocolVersion",
|
|
"bridgeOnly",
|
|
"databaseDirectAccess",
|
|
"sessionSource",
|
|
"sha256",
|
|
"signerThumbprint");
|
|
string signer = WindowsTrustedPeopleSignatureVerifier
|
|
.NormalizeThumbprint(RequiredString(
|
|
cli,
|
|
"signerThumbprint",
|
|
40,
|
|
64));
|
|
if (RequiredString(cli, "component", 1, 64)
|
|
!= "lserp-agent-cli"
|
|
|| RequiredString(cli, "version", 5, 14)
|
|
!= runtimeCliVersion
|
|
|| RequiredString(cli, "protocolVersion", 1, 16) != "1.0"
|
|
|| !RequiredBoolean(cli, "bridgeOnly")
|
|
|| RequiredBoolean(cli, "databaseDirectAccess")
|
|
|| RequiredString(cli, "sessionSource", 1, 64)
|
|
!= "current_logged_in_erp_process"
|
|
|| !string.Equals(
|
|
RequiredHash(cli, "sha256"),
|
|
runtimeCliSha256,
|
|
StringComparison.Ordinal)
|
|
|| signer == null
|
|
|| !string.Equals(
|
|
signer,
|
|
runtimeCliSignerThumbprint,
|
|
StringComparison.Ordinal))
|
|
throw ReadOnlyPreflightError();
|
|
|
|
JObject rollout = RequiredObject(report, "rolloutPolicy");
|
|
EnsureExact(
|
|
rollout,
|
|
"customerId",
|
|
"databaseScopeFingerprint",
|
|
"sourceSha256",
|
|
"defaultAction",
|
|
"ruleCount");
|
|
if (!string.Equals(
|
|
RequiredString(rollout, "customerId", 1, 128),
|
|
rolloutCustomerId,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
RequiredHash(rollout, "databaseScopeFingerprint"),
|
|
databaseScopeFingerprint,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
RequiredHash(rollout, "sourceSha256"),
|
|
rolloutPolicySha256,
|
|
StringComparison.Ordinal)
|
|
|| RequiredString(rollout, "defaultAction", 1, 16) != "deny")
|
|
throw ReadOnlyPreflightError();
|
|
RequiredLong(rollout, "ruleCount", 1, 128);
|
|
|
|
JObject session = RequiredObject(report, "session");
|
|
EnsureExact(
|
|
session,
|
|
"databaseScopeFingerprint",
|
|
"userIdSha256",
|
|
"userNameSha256",
|
|
"accountBookSha256",
|
|
"subSystemIdSha256",
|
|
"subSystemNameSha256",
|
|
"isAdministrator",
|
|
"activeModule",
|
|
"openModuleCount");
|
|
bool isAdministrator = RequiredBoolean(session, "isAdministrator");
|
|
if (!string.Equals(
|
|
RequiredHash(session, "databaseScopeFingerprint"),
|
|
databaseScopeFingerprint,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
RequiredHash(session, "userIdSha256"),
|
|
ScopedReadOnlyPreflightHash("user-id", userId),
|
|
StringComparison.Ordinal)
|
|
|| (!string.IsNullOrWhiteSpace(userName)
|
|
&& !string.Equals(
|
|
RequiredHash(session, "userNameSha256"),
|
|
ScopedReadOnlyPreflightHash("user-name", userName),
|
|
StringComparison.Ordinal))
|
|
|| !string.Equals(
|
|
RequiredHash(session, "accountBookSha256"),
|
|
ScopedReadOnlyPreflightHash("account-book", accountBook),
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
RequiredHash(session, "subSystemIdSha256"),
|
|
ScopedReadOnlyPreflightHash("subsystem-id", subSystemId),
|
|
StringComparison.Ordinal)
|
|
|| (requireAdministrator && !isAdministrator))
|
|
throw ReadOnlyPreflightError();
|
|
RequiredHash(session, "userNameSha256");
|
|
RequiredHash(session, "subSystemNameSha256");
|
|
RequiredLong(session, "openModuleCount", 0, 10000);
|
|
JToken activeModuleToken = session["activeModule"];
|
|
if (activeModuleToken == null)
|
|
throw ReadOnlyPreflightError();
|
|
if (activeModuleToken.Type != JTokenType.Null)
|
|
{
|
|
JObject activeModule = activeModuleToken as JObject;
|
|
EnsureExact(activeModule, "moduleCode", "navigationCode");
|
|
if (!SafeModule.IsMatch(RequiredString(
|
|
activeModule,
|
|
"moduleCode",
|
|
1,
|
|
64))
|
|
|| !SafeModule.IsMatch(RequiredString(
|
|
activeModule,
|
|
"navigationCode",
|
|
1,
|
|
64)))
|
|
throw ReadOnlyPreflightError();
|
|
}
|
|
|
|
JObject bridge = RequiredObject(report, "bridge");
|
|
EnsureExact(
|
|
bridge,
|
|
"protocolVersion",
|
|
"registeredCommandCount",
|
|
"enabledCommandCount");
|
|
long registeredCount = RequiredLong(
|
|
bridge,
|
|
"registeredCommandCount",
|
|
1,
|
|
512);
|
|
long enabledCount = RequiredLong(
|
|
bridge,
|
|
"enabledCommandCount",
|
|
1,
|
|
512);
|
|
if (RequiredString(bridge, "protocolVersion", 1, 16) != "1.0"
|
|
|| enabledCount > registeredCount)
|
|
throw ReadOnlyPreflightError();
|
|
|
|
JObject requested = RequiredObject(report, "requestedReadiness");
|
|
EnsureExact(
|
|
requested,
|
|
"purchaseWorkflow",
|
|
"leaveWorkflow",
|
|
"diagnosisWorkflow");
|
|
if (RequiredBoolean(requested, "purchaseWorkflow")
|
|
!= requirePurchase
|
|
|| RequiredBoolean(requested, "leaveWorkflow")
|
|
!= requireLeave
|
|
|| RequiredBoolean(requested, "diagnosisWorkflow")
|
|
!= requireDiagnosis)
|
|
throw ReadOnlyPreflightError();
|
|
|
|
Dictionary<string, string[]> expectedCapabilities =
|
|
new Dictionary<string, string[]>(StringComparer.Ordinal)
|
|
{
|
|
{ "module.parameters",
|
|
new[] { "1.1", "1.1", "read" } },
|
|
{ "purchase.invoice.resolve",
|
|
new[] { "1.4", "1.4", "draft" } },
|
|
{ "purchase.invoice.create",
|
|
new[] { "1.4", "1.4", "write" } },
|
|
{ "hr.leave.resolve",
|
|
new[] { "1.4", "1.4", "draft" } },
|
|
{ "hr.leave.create",
|
|
new[] { "1.2", "1.2", "write" } },
|
|
{ "hr.leave.submit",
|
|
new[] { "1.0", "1.0", "write" } },
|
|
{ "module.diagnose",
|
|
new[] { "1.0", "1.0", "read" } },
|
|
{ "module.trace-initialization",
|
|
new[] { "1.2", "1.0", "critical" } }
|
|
};
|
|
JArray capabilities = report["capabilityReadiness"] as JArray;
|
|
if (capabilities == null
|
|
|| capabilities.Count != expectedCapabilities.Count)
|
|
throw ReadOnlyPreflightError();
|
|
HashSet<string> capabilityNames =
|
|
new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (JToken token in capabilities)
|
|
{
|
|
JObject capability = token as JObject;
|
|
EnsureExact(
|
|
capability,
|
|
"command",
|
|
"required",
|
|
"available",
|
|
"contractValid",
|
|
"version",
|
|
"schemaVersion",
|
|
"risk");
|
|
string name = RequiredString(capability, "command", 1, 128);
|
|
string[] expected;
|
|
bool expectedRequired = CapabilityRequired(
|
|
name,
|
|
requirePurchase,
|
|
requireLeave,
|
|
requireDiagnosis);
|
|
bool actualRequired = RequiredBoolean(capability, "required");
|
|
bool available = RequiredBoolean(capability, "available");
|
|
bool contractValid = RequiredBoolean(capability, "contractValid");
|
|
if (!capabilityNames.Add(name)
|
|
|| !expectedCapabilities.TryGetValue(name, out expected)
|
|
|| actualRequired != expectedRequired
|
|
|| (expectedRequired && (!available || !contractValid))
|
|
|| (available && !contractValid)
|
|
|| (!available && contractValid)
|
|
|| !ValidateCapabilityContract(
|
|
capability,
|
|
expected,
|
|
available))
|
|
throw ReadOnlyPreflightError();
|
|
}
|
|
|
|
JArray modules = report["modules"] as JArray;
|
|
if (modules == null || modules.Count < 1 || modules.Count > 16)
|
|
throw ReadOnlyPreflightError();
|
|
HashSet<string> moduleCodes = new HashSet<string>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
HashSet<string> requestedModuleCodes = new HashSet<string>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
foreach (JToken token in modules)
|
|
{
|
|
JObject module = token as JObject;
|
|
string requestedCode;
|
|
string moduleCode = ValidateReadOnlyModuleEvidence(
|
|
module,
|
|
out requestedCode);
|
|
if (!requestedModuleCodes.Add(requestedCode)
|
|
|| !moduleCodes.Add(moduleCode))
|
|
throw ReadOnlyPreflightError();
|
|
}
|
|
if (requiredModuleCodes.Any(item => !moduleCodes.Contains(item)))
|
|
throw ReadOnlyPreflightError();
|
|
|
|
JObject boundary = RequiredObject(report, "readOnlyBoundary");
|
|
EnsureExact(
|
|
boundary,
|
|
"allowedOperations",
|
|
"commandExecuteInvoked",
|
|
"workflowPlanInvoked",
|
|
"directDatabaseConnectionUsed",
|
|
"businessWriteAttempted",
|
|
"rawBusinessValuesEmitted",
|
|
"rawParameterLabelsEmitted",
|
|
"temporaryModuleInputsRetained");
|
|
string[] expectedOperations =
|
|
{
|
|
"cli.version",
|
|
"bridge.health",
|
|
"bridge.context",
|
|
"bridge.capabilities",
|
|
"bridge.plan:module.parameters"
|
|
};
|
|
JArray operations = boundary["allowedOperations"] as JArray;
|
|
if (operations == null || operations.Count != expectedOperations.Length)
|
|
throw ReadOnlyPreflightError();
|
|
for (int index = 0; index < expectedOperations.Length; index++)
|
|
{
|
|
if (operations[index].Type != JTokenType.String
|
|
|| operations[index].Value<string>() != expectedOperations[index])
|
|
throw ReadOnlyPreflightError();
|
|
}
|
|
foreach (string property in new[]
|
|
{
|
|
"commandExecuteInvoked",
|
|
"workflowPlanInvoked",
|
|
"directDatabaseConnectionUsed",
|
|
"businessWriteAttempted",
|
|
"rawBusinessValuesEmitted",
|
|
"rawParameterLabelsEmitted",
|
|
"temporaryModuleInputsRetained"
|
|
})
|
|
{
|
|
if (RequiredBoolean(boundary, property))
|
|
throw ReadOnlyPreflightError();
|
|
}
|
|
|
|
string[] expectedChecks =
|
|
{
|
|
"cli_integrity",
|
|
"cli_runtime_identity",
|
|
"exact_erp_process",
|
|
"default_deny_rollout",
|
|
"rollout_database_scope",
|
|
"expected_database_scope",
|
|
"expected_session_scope",
|
|
"capability_contracts",
|
|
"dynamic_module_contracts",
|
|
"dynamic_module_execution_contracts",
|
|
"session_stability",
|
|
"read_only_boundary"
|
|
};
|
|
JArray checks = report["checks"] as JArray;
|
|
if (checks == null || checks.Count != expectedChecks.Length)
|
|
throw ReadOnlyPreflightError();
|
|
HashSet<string> checkCodes = new HashSet<string>(
|
|
StringComparer.Ordinal);
|
|
foreach (JToken token in checks)
|
|
{
|
|
JObject check = token as JObject;
|
|
EnsureExact(check, "code", "passed");
|
|
string code = RequiredString(check, "code", 1, 128);
|
|
if (!checkCodes.Add(code) || !RequiredBoolean(check, "passed"))
|
|
throw ReadOnlyPreflightError();
|
|
}
|
|
if (expectedChecks.Any(item => !checkCodes.Contains(item)))
|
|
throw ReadOnlyPreflightError();
|
|
RequiredString(report, "note", 1, 512);
|
|
return new ReadOnlySessionPreflightState
|
|
{
|
|
GeneratedAtUtcText = generatedAt.ToString("o", CultureInfo.InvariantCulture),
|
|
GeneratedAtUtc = generatedAt,
|
|
ErpProcessId = erpProcessId,
|
|
ErpSha256 = erpSha256,
|
|
ErpSignerThumbprint = erpSignerThumbprint,
|
|
RuntimeCliVersion = runtimeCliVersion,
|
|
RuntimeCliSha256 = runtimeCliSha256,
|
|
RuntimeCliSignerThumbprint = runtimeCliSignerThumbprint,
|
|
ModuleCount = modules.Count,
|
|
IsAdministrator = isAdministrator
|
|
};
|
|
}
|
|
|
|
private static bool CapabilityRequired(
|
|
string name,
|
|
bool requirePurchase,
|
|
bool requireLeave,
|
|
bool requireDiagnosis)
|
|
{
|
|
if (name == "module.parameters") return true;
|
|
if (name.StartsWith("purchase.", StringComparison.Ordinal))
|
|
return requirePurchase;
|
|
if (name.StartsWith("hr.leave.", StringComparison.Ordinal))
|
|
return requireLeave;
|
|
if (name == "module.diagnose"
|
|
|| name == "module.trace-initialization")
|
|
return requireDiagnosis;
|
|
return false;
|
|
}
|
|
|
|
private static bool ValidateCapabilityContract(
|
|
JObject capability,
|
|
string[] expected,
|
|
bool available)
|
|
{
|
|
foreach (string property in new[] { "version", "schemaVersion", "risk" })
|
|
{
|
|
JToken token = capability[property];
|
|
if (token == null) return false;
|
|
if (!available)
|
|
{
|
|
if (token.Type != JTokenType.Null) return false;
|
|
continue;
|
|
}
|
|
if (token.Type != JTokenType.String) return false;
|
|
}
|
|
return !available
|
|
|| (capability.Value<string>("version") == expected[0]
|
|
&& capability.Value<string>("schemaVersion") == expected[1]
|
|
&& capability.Value<string>("risk") == expected[2]);
|
|
}
|
|
|
|
private static FieldReadOnlyValidationHandoffState
|
|
VerifyFieldReadOnlyValidationHandoff(
|
|
string path,
|
|
DateTime expectedGeneratedAtUtc,
|
|
DateTime issuedAtUtc,
|
|
string readOnlySessionPreflightSha256,
|
|
ReadOnlySessionPreflightState readOnlySession,
|
|
string expectedRole,
|
|
int minimumExpectedRoleCount)
|
|
{
|
|
try
|
|
{
|
|
return VerifyFieldReadOnlyValidationHandoffCore(
|
|
path,
|
|
expectedGeneratedAtUtc,
|
|
issuedAtUtc,
|
|
readOnlySessionPreflightSha256,
|
|
readOnlySession,
|
|
expectedRole,
|
|
minimumExpectedRoleCount);
|
|
}
|
|
catch (CommandKernelException error)
|
|
{
|
|
if (string.Equals(
|
|
error.Code,
|
|
"customer_acceptance_bundle_field_handoff_invalid",
|
|
StringComparison.Ordinal))
|
|
throw;
|
|
throw FieldReadOnlyHandoffError();
|
|
}
|
|
catch
|
|
{
|
|
throw FieldReadOnlyHandoffError();
|
|
}
|
|
}
|
|
|
|
private static FieldReadOnlyValidationHandoffState
|
|
VerifyFieldReadOnlyValidationHandoffCore(
|
|
string path,
|
|
DateTime expectedGeneratedAtUtc,
|
|
DateTime issuedAtUtc,
|
|
string readOnlySessionPreflightSha256,
|
|
ReadOnlySessionPreflightState readOnlySession,
|
|
string expectedRole,
|
|
int minimumExpectedRoleCount)
|
|
{
|
|
if ((expectedRole != "purchase"
|
|
&& expectedRole != "leave"
|
|
&& expectedRole != "diagnosis")
|
|
|| minimumExpectedRoleCount < 1
|
|
|| minimumExpectedRoleCount > 16)
|
|
throw FieldReadOnlyHandoffError();
|
|
JObject report = LoadStrict(
|
|
path,
|
|
256 * 1024,
|
|
"现场只读参数交接报告");
|
|
EnsureExact(
|
|
report,
|
|
"schemaVersion",
|
|
"evidenceType",
|
|
"generatedAtUtc",
|
|
"passed",
|
|
"validationStage",
|
|
"inputSha256",
|
|
"approvedBySha256",
|
|
"approvedAtUtc",
|
|
"approvalExpiresAtUtc",
|
|
"expectedPreflightScriptSha256",
|
|
"handoffContractSha256",
|
|
"runtimeCli",
|
|
"sessionApprovalFingerprint",
|
|
"erpProcessId",
|
|
"expectedIsAdministrator",
|
|
"moduleBindingFingerprint",
|
|
"moduleBindingCount",
|
|
"uniqueModuleCount",
|
|
"moduleRoleCounts",
|
|
"requestedReadiness",
|
|
"databaseCredentialsIncluded",
|
|
"validationOnly",
|
|
"executionAttempted",
|
|
"preflightEvidenceProduced",
|
|
"preflightEvidenceSha256",
|
|
"productionWriteAuthorized",
|
|
"readOnlyBoundary",
|
|
"checks",
|
|
"note");
|
|
DateTime generatedAt = RequiredUtc(report, "generatedAtUtc");
|
|
DateTime approvedAt = RequiredUtc(report, "approvedAtUtc");
|
|
DateTime approvalExpiresAt = RequiredUtc(
|
|
report,
|
|
"approvalExpiresAtUtc");
|
|
if (RequiredString(report, "schemaVersion", 1, 16) != "1.1"
|
|
|| RequiredString(report, "evidenceType", 1, 64)
|
|
!= "lserp_field_readonly_validation_handoff"
|
|
|| RequiredString(report, "validationStage", 1, 16) != "final"
|
|
|| !RequiredBoolean(report, "passed")
|
|
|| Math.Abs((generatedAt - expectedGeneratedAtUtc).TotalSeconds)
|
|
> 1
|
|
|| generatedAt > issuedAtUtc.AddMinutes(5)
|
|
|| generatedAt < issuedAtUtc.AddDays(-7)
|
|
|| generatedAt < approvedAt
|
|
|| generatedAt > approvalExpiresAt
|
|
|| approvalExpiresAt <= approvedAt
|
|
|| approvalExpiresAt - approvedAt > TimeSpan.FromHours(24)
|
|
|| generatedAt < readOnlySession.GeneratedAtUtc.AddSeconds(-1)
|
|
|| checked((int)RequiredLong(
|
|
report,
|
|
"erpProcessId",
|
|
1,
|
|
int.MaxValue)) != readOnlySession.ErpProcessId
|
|
|| RequiredBoolean(report, "expectedIsAdministrator")
|
|
!= readOnlySession.IsAdministrator
|
|
|| RequiredBoolean(report, "databaseCredentialsIncluded")
|
|
|| RequiredBoolean(report, "validationOnly")
|
|
|| !RequiredBoolean(report, "executionAttempted")
|
|
|| !RequiredBoolean(report, "preflightEvidenceProduced")
|
|
|| RequiredBoolean(report, "productionWriteAuthorized"))
|
|
throw FieldReadOnlyHandoffError();
|
|
|
|
JObject runtimeCli = RequiredObject(report, "runtimeCli");
|
|
EnsureExact(
|
|
runtimeCli,
|
|
"component",
|
|
"version",
|
|
"sha256",
|
|
"signerThumbprint");
|
|
string runtimeSigner = WindowsTrustedPeopleSignatureVerifier
|
|
.NormalizeThumbprint(RequiredString(
|
|
runtimeCli,
|
|
"signerThumbprint",
|
|
40,
|
|
64));
|
|
if (RequiredString(runtimeCli, "component", 1, 64)
|
|
!= "lserp-agent-cli"
|
|
|| RequiredString(runtimeCli, "version", 5, 14)
|
|
!= readOnlySession.RuntimeCliVersion
|
|
|| RequiredHash(runtimeCli, "sha256")
|
|
!= readOnlySession.RuntimeCliSha256
|
|
|| runtimeSigner == null
|
|
|| runtimeSigner != readOnlySession.RuntimeCliSignerThumbprint)
|
|
throw FieldReadOnlyHandoffError();
|
|
|
|
string zeroHash = new string('0', 64);
|
|
foreach (string property in new[]
|
|
{
|
|
"inputSha256",
|
|
"approvedBySha256",
|
|
"expectedPreflightScriptSha256",
|
|
"handoffContractSha256",
|
|
"sessionApprovalFingerprint",
|
|
"moduleBindingFingerprint"
|
|
})
|
|
{
|
|
if (RequiredHash(report, property) == zeroHash)
|
|
throw FieldReadOnlyHandoffError();
|
|
}
|
|
if (!string.Equals(
|
|
RequiredHash(report, "preflightEvidenceSha256"),
|
|
readOnlySessionPreflightSha256,
|
|
StringComparison.Ordinal))
|
|
throw FieldReadOnlyHandoffError();
|
|
|
|
long bindingCount = RequiredLong(
|
|
report,
|
|
"moduleBindingCount",
|
|
1,
|
|
16);
|
|
long uniqueModuleCount = RequiredLong(
|
|
report,
|
|
"uniqueModuleCount",
|
|
1,
|
|
16);
|
|
JObject roleCounts = RequiredObject(report, "moduleRoleCounts");
|
|
EnsureExact(roleCounts, "purchase", "leave", "diagnosis", "support");
|
|
long purchaseCount = RequiredLong(roleCounts, "purchase", 0, 16);
|
|
long leaveCount = RequiredLong(roleCounts, "leave", 0, 16);
|
|
long diagnosisCount = RequiredLong(roleCounts, "diagnosis", 0, 16);
|
|
long supportCount = RequiredLong(roleCounts, "support", 0, 16);
|
|
if (uniqueModuleCount != readOnlySession.ModuleCount
|
|
|| purchaseCount + leaveCount + diagnosisCount + supportCount
|
|
!= bindingCount
|
|
|| (expectedRole == "purchase"
|
|
? purchaseCount < minimumExpectedRoleCount
|
|
|| leaveCount != 0 || diagnosisCount != 0
|
|
: expectedRole == "leave"
|
|
? leaveCount < minimumExpectedRoleCount
|
|
|| purchaseCount != 0 || diagnosisCount != 0
|
|
: diagnosisCount < minimumExpectedRoleCount
|
|
|| purchaseCount != 0 || leaveCount != 0))
|
|
throw FieldReadOnlyHandoffError();
|
|
|
|
JObject requested = RequiredObject(report, "requestedReadiness");
|
|
EnsureExact(
|
|
requested,
|
|
"purchaseWorkflow",
|
|
"leaveWorkflow",
|
|
"diagnosisWorkflow");
|
|
if (RequiredBoolean(requested, "purchaseWorkflow")
|
|
!= (expectedRole == "purchase")
|
|
|| RequiredBoolean(requested, "leaveWorkflow")
|
|
!= (expectedRole == "leave")
|
|
|| RequiredBoolean(requested, "diagnosisWorkflow")
|
|
!= (expectedRole == "diagnosis"))
|
|
throw FieldReadOnlyHandoffError();
|
|
|
|
JObject boundary = RequiredObject(report, "readOnlyBoundary");
|
|
string[] boundaryFields =
|
|
{
|
|
"directDatabaseConnectionUsed",
|
|
"databaseCredentialAccepted",
|
|
"businessWriteAttempted",
|
|
"rawSessionValuesEmitted",
|
|
"rawModuleCodesEmitted",
|
|
"preflightExecuteCommandAllowed"
|
|
};
|
|
EnsureExact(boundary, boundaryFields);
|
|
foreach (string property in boundaryFields)
|
|
{
|
|
if (RequiredBoolean(boundary, property))
|
|
throw FieldReadOnlyHandoffError();
|
|
}
|
|
|
|
string[] expectedChecks =
|
|
{
|
|
"strict_json",
|
|
"input_integrity",
|
|
"independent_approval_window",
|
|
"database_credentials_absent",
|
|
"exact_session_expectations",
|
|
"dynamic_module_role_bindings",
|
|
"preflight_source_integrity",
|
|
"readonly_parameter_handoff"
|
|
};
|
|
JArray checks = report["checks"] as JArray;
|
|
if (checks == null || checks.Count != expectedChecks.Length)
|
|
throw FieldReadOnlyHandoffError();
|
|
HashSet<string> checkCodes = new HashSet<string>(
|
|
StringComparer.Ordinal);
|
|
foreach (JToken token in checks)
|
|
{
|
|
JObject check = token as JObject;
|
|
EnsureExact(check, "code", "passed");
|
|
string code = RequiredString(check, "code", 1, 128);
|
|
if (!checkCodes.Add(code) || !RequiredBoolean(check, "passed"))
|
|
throw FieldReadOnlyHandoffError();
|
|
}
|
|
if (expectedChecks.Any(item => !checkCodes.Contains(item)))
|
|
throw FieldReadOnlyHandoffError();
|
|
RequiredString(report, "note", 1, 512);
|
|
return new FieldReadOnlyValidationHandoffState
|
|
{
|
|
GeneratedAtUtcText = generatedAt.ToString(
|
|
"o",
|
|
CultureInfo.InvariantCulture)
|
|
};
|
|
}
|
|
|
|
private static CommandKernelException FieldReadOnlyHandoffError()
|
|
{
|
|
return Error(
|
|
"customer_acceptance_bundle_field_handoff_invalid",
|
|
"现场只读参数交接报告无效,或未绑定同一个最终只读会话预检。" );
|
|
}
|
|
|
|
private static string ValidateReadOnlyModuleEvidence(
|
|
JObject module,
|
|
out string requestedCode)
|
|
{
|
|
EnsureExact(
|
|
module,
|
|
"requestedCode",
|
|
"moduleCode",
|
|
"moduleKind",
|
|
"contractSource",
|
|
"sessionScopeBound",
|
|
"configurationFingerprint",
|
|
"contractFingerprint",
|
|
"masterParameterCount",
|
|
"detailParameterCount",
|
|
"inputModeCounts",
|
|
"editableParameterCount",
|
|
"requiredParameterCount",
|
|
"dedicatedAdapterParameterCount",
|
|
"nativeExecutionProfiles",
|
|
"payloadContract",
|
|
"genericWriteExecutionAvailable",
|
|
"writeExecutionBlocked",
|
|
"writeCommand",
|
|
"writeReadinessCode",
|
|
"genericUpdateExecutionAvailable",
|
|
"updateExecutionBlocked",
|
|
"updateCommand",
|
|
"updateReadinessCode",
|
|
"planExecutionAllowed");
|
|
requestedCode = RequiredString(module, "requestedCode", 1, 64);
|
|
string moduleCode = RequiredString(module, "moduleCode", 1, 64);
|
|
string moduleKind = RequiredString(module, "moduleKind", 1, 32);
|
|
if (!SafeModule.IsMatch(requestedCode)
|
|
|| !SafeModule.IsMatch(moduleCode)
|
|
|| (moduleKind != "document" && moduleKind != "master_data")
|
|
|| RequiredString(module, "contractSource", 1, 128)
|
|
!= "current_erp_database_low_code_configuration"
|
|
|| !RequiredBoolean(module, "sessionScopeBound")
|
|
|| RequiredBoolean(module, "planExecutionAllowed"))
|
|
throw ReadOnlyPreflightError();
|
|
RequiredHash(module, "configurationFingerprint");
|
|
RequiredHash(module, "contractFingerprint");
|
|
|
|
long masterCount = RequiredLong(
|
|
module,
|
|
"masterParameterCount",
|
|
0,
|
|
512);
|
|
long detailCount = RequiredLong(
|
|
module,
|
|
"detailParameterCount",
|
|
0,
|
|
512);
|
|
long totalCount = masterCount + detailCount;
|
|
if (totalCount > 512
|
|
|| (moduleKind == "master_data" && detailCount != 0))
|
|
throw ReadOnlyPreflightError();
|
|
JObject inputModes = RequiredObject(module, "inputModeCounts");
|
|
EnsureExact(inputModes, "scalar", "lookupSingle", "unsupported");
|
|
long scalar = RequiredLong(inputModes, "scalar", 0, totalCount);
|
|
long lookup = RequiredLong(
|
|
inputModes,
|
|
"lookupSingle",
|
|
0,
|
|
totalCount);
|
|
long unsupported = RequiredLong(
|
|
inputModes,
|
|
"unsupported",
|
|
0,
|
|
totalCount);
|
|
long editable = RequiredLong(
|
|
module,
|
|
"editableParameterCount",
|
|
0,
|
|
totalCount);
|
|
long required = RequiredLong(
|
|
module,
|
|
"requiredParameterCount",
|
|
0,
|
|
totalCount);
|
|
long dedicated = RequiredLong(
|
|
module,
|
|
"dedicatedAdapterParameterCount",
|
|
0,
|
|
totalCount);
|
|
if (scalar + lookup + unsupported != totalCount
|
|
|| required > editable
|
|
|| editable > scalar + lookup
|
|
|| dedicated != unsupported)
|
|
throw ReadOnlyPreflightError();
|
|
|
|
JObject nativeProfiles = RequiredObject(
|
|
module,
|
|
"nativeExecutionProfiles");
|
|
EnsureExact(nativeProfiles, "create", "update");
|
|
bool createNativeAvailable = ValidateReadOnlyNativeExecutionProfile(
|
|
RequiredObject(nativeProfiles, "create"),
|
|
"create",
|
|
moduleKind);
|
|
bool updateNativeAvailable = ValidateReadOnlyNativeExecutionProfile(
|
|
RequiredObject(nativeProfiles, "update"),
|
|
"update",
|
|
moduleKind);
|
|
ValidateReadOnlyParameterPayload(
|
|
RequiredObject(module, "payloadContract"),
|
|
moduleKind,
|
|
masterCount,
|
|
detailCount,
|
|
editable,
|
|
required);
|
|
ValidateReadOnlyExecutionReadiness(
|
|
module,
|
|
"write",
|
|
"genericWriteExecutionAvailable",
|
|
"writeExecutionBlocked",
|
|
"writeCommand",
|
|
"writeReadinessCode",
|
|
"module.record.create",
|
|
"dynamic_module_write_ready",
|
|
createNativeAvailable,
|
|
moduleKind);
|
|
ValidateReadOnlyExecutionReadiness(
|
|
module,
|
|
"update",
|
|
"genericUpdateExecutionAvailable",
|
|
"updateExecutionBlocked",
|
|
"updateCommand",
|
|
"updateReadinessCode",
|
|
"module.record.resolve-update",
|
|
"dynamic_module_update_ready",
|
|
updateNativeAvailable,
|
|
moduleKind);
|
|
return moduleCode;
|
|
}
|
|
|
|
private static bool ValidateReadOnlyNativeExecutionProfile(
|
|
JObject profile,
|
|
string action,
|
|
string moduleKind)
|
|
{
|
|
EnsureExact(
|
|
profile,
|
|
"available",
|
|
"nativeSaveFamily",
|
|
"profileFingerprint",
|
|
"code");
|
|
bool available = RequiredBoolean(profile, "available");
|
|
JToken familyToken = profile["nativeSaveFamily"];
|
|
JToken fingerprintToken = profile["profileFingerprint"];
|
|
JToken codeToken = profile["code"];
|
|
if (familyToken == null || fingerprintToken == null || codeToken == null)
|
|
throw ReadOnlyPreflightError();
|
|
if (available)
|
|
{
|
|
string family = RequiredString(
|
|
profile,
|
|
"nativeSaveFamily",
|
|
1,
|
|
128);
|
|
RequiredHash(profile, "profileFingerprint");
|
|
if (codeToken.Type != JTokenType.Null
|
|
|| !AllowedReadOnlyNativeSaveFamily(moduleKind, family)
|
|
|| (action == "update" && moduleKind == "document"))
|
|
throw ReadOnlyPreflightError();
|
|
return true;
|
|
}
|
|
|
|
if (familyToken.Type != JTokenType.Null
|
|
|| fingerprintToken.Type != JTokenType.Null)
|
|
throw ReadOnlyPreflightError();
|
|
string code = RequiredString(profile, "code", 1, 128);
|
|
if (!SafeReadinessCode.IsMatch(code)
|
|
|| (action == "update"
|
|
&& moduleKind == "document"
|
|
&& code != "dynamic_module_update_bill_unsupported"))
|
|
throw ReadOnlyPreflightError();
|
|
return false;
|
|
}
|
|
|
|
private static bool AllowedReadOnlyNativeSaveFamily(
|
|
string moduleKind,
|
|
string family)
|
|
{
|
|
if (moduleKind == "document")
|
|
return family == "legacy.bill-save.p-bill-save-pr3"
|
|
|| family == "legacy.bill-save.p-bill-save-pr70";
|
|
return family == "legacy.base-save.p-base-save"
|
|
|| family == "legacy.base-save.p-base-save70";
|
|
}
|
|
|
|
private static void ValidateReadOnlyParameterPayload(
|
|
JObject payload,
|
|
string moduleKind,
|
|
long masterCount,
|
|
long detailCount,
|
|
long editableCount,
|
|
long requiredCount)
|
|
{
|
|
EnsureExact(
|
|
payload,
|
|
"format",
|
|
"valueEncoding",
|
|
"moduleCodeRequired",
|
|
"masterValuesRequired",
|
|
"detailRowsRequired",
|
|
"minimumDetailRows",
|
|
"maximumDetailRows",
|
|
"masterParameterIdCount",
|
|
"requiredMasterParameterIdCount",
|
|
"detailParameterIdCount",
|
|
"requiredDetailParameterIdCount",
|
|
"unknownParameterPolicy",
|
|
"duplicateParameterPolicy",
|
|
"lookupPolicy",
|
|
"fieldConstraintPolicy",
|
|
"configurationDriftPolicy");
|
|
bool document = moduleKind == "document";
|
|
long masterIds = RequiredLong(
|
|
payload,
|
|
"masterParameterIdCount",
|
|
0,
|
|
masterCount);
|
|
long requiredMasterIds = RequiredLong(
|
|
payload,
|
|
"requiredMasterParameterIdCount",
|
|
0,
|
|
masterCount);
|
|
long detailIds = RequiredLong(
|
|
payload,
|
|
"detailParameterIdCount",
|
|
0,
|
|
detailCount);
|
|
long requiredDetailIds = RequiredLong(
|
|
payload,
|
|
"requiredDetailParameterIdCount",
|
|
0,
|
|
detailCount);
|
|
if (RequiredString(payload, "format", 1, 64)
|
|
!= "parameter_entries_v1"
|
|
|| RequiredString(payload, "valueEncoding", 1, 64)
|
|
!= "invariant_text"
|
|
|| !RequiredBoolean(payload, "moduleCodeRequired")
|
|
|| !RequiredBoolean(payload, "masterValuesRequired")
|
|
|| RequiredBoolean(payload, "detailRowsRequired") != document
|
|
|| RequiredLong(payload, "minimumDetailRows", 0, 1)
|
|
!= (document ? 1 : 0)
|
|
|| RequiredLong(payload, "maximumDetailRows", 0, 1000)
|
|
!= (document ? 1000 : 0)
|
|
|| requiredMasterIds > masterIds
|
|
|| requiredDetailIds > detailIds
|
|
|| masterIds + detailIds != editableCount
|
|
|| requiredMasterIds + requiredDetailIds != requiredCount
|
|
|| (!document && (detailIds != 0 || requiredDetailIds != 0))
|
|
|| RequiredString(payload, "unknownParameterPolicy", 1, 64)
|
|
!= "reject"
|
|
|| RequiredString(payload, "duplicateParameterPolicy", 1, 64)
|
|
!= "reject"
|
|
|| RequiredString(payload, "lookupPolicy", 1, 128)
|
|
!= "server_resolve_unique_or_stop"
|
|
|| RequiredString(payload, "fieldConstraintPolicy", 1, 128)
|
|
!= "server_enforced_from_current_low_code_configuration"
|
|
|| RequiredString(payload, "configurationDriftPolicy", 1, 64)
|
|
!= "reject_and_replan")
|
|
throw ReadOnlyPreflightError();
|
|
}
|
|
|
|
private static void ValidateReadOnlyExecutionReadiness(
|
|
JObject module,
|
|
string action,
|
|
string availableName,
|
|
string blockedName,
|
|
string commandName,
|
|
string readinessName,
|
|
string expectedCommand,
|
|
string expectedReadyCode,
|
|
bool nativeAvailable,
|
|
string moduleKind)
|
|
{
|
|
bool available = RequiredBoolean(module, availableName);
|
|
if (RequiredBoolean(module, blockedName) == available)
|
|
throw ReadOnlyPreflightError();
|
|
ValidateNullableReadinessCommand(
|
|
module,
|
|
commandName,
|
|
available,
|
|
expectedCommand);
|
|
string readinessCode = RequiredString(
|
|
module,
|
|
readinessName,
|
|
1,
|
|
128);
|
|
if (!SafeReadinessCode.IsMatch(readinessCode)
|
|
|| (available
|
|
&& (!nativeAvailable
|
|
|| readinessCode != expectedReadyCode))
|
|
|| (!available && readinessCode == expectedReadyCode)
|
|
|| (action == "update"
|
|
&& moduleKind == "document"
|
|
&& (available
|
|
|| readinessCode
|
|
!= "dynamic_module_update_bill_unsupported")))
|
|
throw ReadOnlyPreflightError();
|
|
}
|
|
|
|
private static void ValidateNullableReadinessCommand(
|
|
JObject source,
|
|
string name,
|
|
bool available,
|
|
string expectedCommand)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null
|
|
|| (available
|
|
&& (token.Type != JTokenType.String
|
|
|| token.Value<string>() != expectedCommand))
|
|
|| (!available && token.Type != JTokenType.Null))
|
|
throw ReadOnlyPreflightError();
|
|
}
|
|
|
|
private static string ScopedReadOnlyPreflightHash(
|
|
string scope,
|
|
string value)
|
|
{
|
|
return Sha256(Encoding.UTF8.GetBytes(
|
|
"lserp-readonly-preflight-v1|"
|
|
+ (scope ?? string.Empty)
|
|
+ "|"
|
|
+ (value ?? string.Empty)));
|
|
}
|
|
|
|
private static CommandKernelException ReadOnlyPreflightError()
|
|
{
|
|
return Error(
|
|
"customer_acceptance_bundle_readonly_preflight_invalid",
|
|
"指定 ERP 会话的只读动态合同预检报告无效或与最终验收范围不一致。");
|
|
}
|
|
|
|
private static PreflightVerificationState VerifyPreflight(
|
|
string path,
|
|
string sourceCommit,
|
|
DateTime expectedGeneratedAt,
|
|
DateTime issuedAtUtc)
|
|
{
|
|
JObject report = LoadStrict(path, 4 * 1024 * 1024, "商用预检报告");
|
|
EnsureExact(report,
|
|
"schemaVersion", "generatedAtUtc", "passed",
|
|
"packageManifestVerified", "packageSha256", "packageSourceCommit",
|
|
"legacyBuildEvidenceSha256", "rolloutPolicySha256",
|
|
"rolloutCustomerId", "spriteSha256",
|
|
"spriteLicenseEvidenceSha256",
|
|
"astrBotComplianceEvidenceSha256",
|
|
"miniMaxServiceComplianceEvidenceSha256",
|
|
"miniMaxIntegrationMode",
|
|
"miniMaxVisionProbeEvidenceSha256",
|
|
"miniMaxVisionProbeObservedAtUtc", "miniMaxVisionProbeRegion",
|
|
"miniMaxVisionProbeContractVersion", "checks", "nextStep");
|
|
DateTime generatedAt = RequiredUtc(report, "generatedAtUtc");
|
|
if (RequiredString(report, "schemaVersion", 1, 16) != "1.7"
|
|
|| !RequiredBoolean(report, "passed")
|
|
|| !RequiredBoolean(report, "packageManifestVerified")
|
|
|| !Same(RequiredString(report, "packageSourceCommit", 40, 40),
|
|
sourceCommit)
|
|
|| Math.Abs((generatedAt - expectedGeneratedAt).TotalSeconds) > 1
|
|
|| generatedAt > issuedAtUtc.AddMinutes(5)
|
|
|| generatedAt < issuedAtUtc.AddDays(-7))
|
|
throw Error("customer_acceptance_bundle_preflight_invalid", "商用预检报告未通过或来源不一致。");
|
|
string packageHash = RequiredHash(report, "packageSha256");
|
|
string legacyHash = RequiredHash(report, "legacyBuildEvidenceSha256");
|
|
string rolloutHash = RequiredHash(report, "rolloutPolicySha256");
|
|
string rolloutCustomerId = RequiredString(
|
|
report, "rolloutCustomerId", 1, 64);
|
|
if (!SafeIdentifier.IsMatch(rolloutCustomerId))
|
|
throw Error("customer_acceptance_bundle_preflight_invalid", "商用预检客户部署标识无效。");
|
|
RequiredHash(report, "spriteSha256");
|
|
RequiredHash(report, "spriteLicenseEvidenceSha256");
|
|
RequiredHash(report, "astrBotComplianceEvidenceSha256");
|
|
RequiredHash(report, "miniMaxServiceComplianceEvidenceSha256");
|
|
if (RequiredString(report, "miniMaxIntegrationMode", 1, 64)
|
|
!= "direct_https_vlm")
|
|
throw Error("customer_acceptance_bundle_preflight_invalid",
|
|
"商用预检 MiniMax 集成模式不是受审的 HTTPS VLM 直连模式。");
|
|
string miniMaxProbeHash = RequiredHash(
|
|
report, "miniMaxVisionProbeEvidenceSha256");
|
|
string miniMaxProbeObservedAtText = RequiredString(
|
|
report, "miniMaxVisionProbeObservedAtUtc", 20, 64);
|
|
DateTime miniMaxProbeObservedAt = RequiredProbeUtc(
|
|
miniMaxProbeObservedAtText,
|
|
"miniMaxVisionProbeObservedAtUtc");
|
|
string miniMaxProbeRegion = RequiredString(
|
|
report, "miniMaxVisionProbeRegion", 2, 16);
|
|
string miniMaxProbeContractVersion = RequiredString(
|
|
report, "miniMaxVisionProbeContractVersion", 1, 16);
|
|
if ((miniMaxProbeRegion != "cn" && miniMaxProbeRegion != "global")
|
|
|| miniMaxProbeContractVersion != "0.0.4"
|
|
|| miniMaxProbeObservedAt > generatedAt.AddMinutes(5)
|
|
|| miniMaxProbeObservedAt < generatedAt.AddHours(-24))
|
|
throw Error("customer_acceptance_bundle_preflight_invalid",
|
|
"商用预检 MiniMax 在线视觉探针区域、合同或时效无效。");
|
|
JArray checks = report["checks"] as JArray;
|
|
if (checks == null || checks.Count < RequiredPreflightChecks.Length
|
|
|| checks.Count > 64)
|
|
throw Error("customer_acceptance_bundle_preflight_invalid", "商用预检检查项数量无效。");
|
|
HashSet<string> names = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (JToken token in checks)
|
|
{
|
|
JObject check = token as JObject;
|
|
EnsureExact(check, "name", "passed", "code", "detail");
|
|
string name = RequiredString(check, "name", 1, 128);
|
|
if (!names.Add(name) || !RequiredBoolean(check, "passed")
|
|
|| RequiredString(check, "code", 1, 128) != "ok")
|
|
throw Error("customer_acceptance_bundle_preflight_invalid", "商用预检包含重复或失败检查项。");
|
|
RequiredString(check, "detail", 1, 1000);
|
|
}
|
|
if (RequiredPreflightChecks.Any(item => !names.Contains(item)))
|
|
throw Error("customer_acceptance_bundle_preflight_invalid", "商用预检缺少当前版本必需门禁。");
|
|
RequiredString(report, "nextStep", 1, 500);
|
|
return new PreflightVerificationState
|
|
{
|
|
PackageSha256 = packageHash,
|
|
LegacyBuildEvidenceSha256 = legacyHash,
|
|
RolloutPolicySha256 = rolloutHash,
|
|
RolloutCustomerId = rolloutCustomerId,
|
|
MiniMaxVisionProbeEvidenceSha256 = miniMaxProbeHash,
|
|
MiniMaxVisionProbeObservedAtUtc = miniMaxProbeObservedAt,
|
|
MiniMaxVisionProbeRegion = miniMaxProbeRegion,
|
|
MiniMaxVisionProbeContractVersion = miniMaxProbeContractVersion
|
|
};
|
|
}
|
|
|
|
private static MiniMaxVisionProbeState VerifyMiniMaxVisionProbe(
|
|
string path,
|
|
DateTime preflightGeneratedAtUtc,
|
|
DateTime issuedAtUtc)
|
|
{
|
|
JObject report = LoadStrict(path, 64 * 1024,
|
|
"MiniMax 在线视觉探针报告");
|
|
EnsureExact(report,
|
|
"schemaVersion", "observedAtUtc", "passed", "region", "endpoint",
|
|
"contract", "syntheticSourceSha256", "result");
|
|
if (RequiredString(report, "schemaVersion", 1, 16) != "1.0"
|
|
|| !RequiredBoolean(report, "passed"))
|
|
throw Error("customer_acceptance_bundle_minimax_probe_invalid",
|
|
"MiniMax 在线视觉探针没有通过当前报告合同。");
|
|
string observedAtText = RequiredString(
|
|
report, "observedAtUtc", 20, 64);
|
|
if (!Regex.IsMatch(
|
|
observedAtText,
|
|
@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?\+00:00$",
|
|
RegexOptions.CultureInvariant))
|
|
throw Error("customer_acceptance_bundle_minimax_probe_invalid",
|
|
"MiniMax 在线视觉探针原始时间格式不是探针生成器合同。");
|
|
DateTime observedAt = RequiredProbeUtc(observedAtText, "observedAtUtc");
|
|
string region = RequiredString(report, "region", 2, 16);
|
|
string endpoint = RequiredString(report, "endpoint", 1, 256);
|
|
string expectedEndpoint = region == "cn"
|
|
? "https://api.minimaxi.com/v1/coding_plan/vlm"
|
|
: region == "global"
|
|
? "https://api.minimax.io/v1/coding_plan/vlm"
|
|
: string.Empty;
|
|
if (expectedEndpoint.Length == 0
|
|
|| !string.Equals(endpoint, expectedEndpoint, StringComparison.Ordinal)
|
|
|| observedAt > preflightGeneratedAtUtc.AddMinutes(5)
|
|
|| observedAt < preflightGeneratedAtUtc.AddHours(-24)
|
|
|| observedAt > issuedAtUtc.AddMinutes(5))
|
|
throw Error("customer_acceptance_bundle_minimax_probe_invalid",
|
|
"MiniMax 在线视觉探针区域、端点或 24 小时时效无效。");
|
|
|
|
JObject contract = RequiredObject(report, "contract");
|
|
EnsureExact(contract,
|
|
"component", "version", "sourceCommit", "clientSourceSha256",
|
|
"serverSourceSha256", "apiSourceHeader");
|
|
string version = RequiredString(contract, "version", 1, 16);
|
|
if (RequiredString(contract, "component", 1, 64)
|
|
!= "minimax-coding-plan-mcp"
|
|
|| version != "0.0.4"
|
|
|| RequiredString(contract, "sourceCommit", 40, 40)
|
|
!= "fbac3b3e56922a1249e00eebe07d9ee68f4768dc"
|
|
|| RequiredHash(contract, "clientSourceSha256")
|
|
!= "08d4116a20e8a652ceb9e2b6f58b1e7cdfe464b14baff05977e08b4b05b66be3"
|
|
|| RequiredHash(contract, "serverSourceSha256")
|
|
!= "1dea28d6ba4ee46ba516d7eeedd325a5a102410bb7abb074fc4b0a8a66571864"
|
|
|| RequiredString(contract, "apiSourceHeader", 1, 64)
|
|
!= "Minimax-MCP"
|
|
|| RequiredHash(report, "syntheticSourceSha256")
|
|
!= "d37476a5273821c12ee4a72b512dc152db5729055b6febb8603985f86243abda")
|
|
throw Error("customer_acceptance_bundle_minimax_probe_invalid",
|
|
"MiniMax 在线视觉探针来源或固定合成图片合同无效。");
|
|
|
|
JObject result = RequiredObject(report, "result");
|
|
EnsureExact(result,
|
|
"schemaVersion", "documentType", "lineCount",
|
|
"uncertainFieldCount", "contentSha256");
|
|
string documentType = RequiredString(result, "documentType", 1, 32);
|
|
if (RequiredString(result, "schemaVersion", 1, 16) != "1.0"
|
|
|| (documentType != "purchase_invoice"
|
|
&& documentType != "purchase_detail"
|
|
&& documentType != "unknown"))
|
|
throw Error("customer_acceptance_bundle_minimax_probe_invalid",
|
|
"MiniMax 在线视觉探针结果合同无效。");
|
|
RequiredLong(result, "lineCount", 0, 10000);
|
|
RequiredLong(result, "uncertainFieldCount", 0, 10000);
|
|
RequiredHash(result, "contentSha256");
|
|
return new MiniMaxVisionProbeState
|
|
{
|
|
ObservedAtUtcText = observedAtText,
|
|
ObservedAtUtc = observedAt,
|
|
Region = region,
|
|
ContractVersion = version
|
|
};
|
|
}
|
|
|
|
private static LegacyBuildVerificationState VerifyLegacyBuildEvidence(
|
|
string path,
|
|
string sourceCommit)
|
|
{
|
|
JObject evidence = LoadStrict(path, 8 * 1024 * 1024, "旧 ERP 构建证据");
|
|
if (RequiredString(evidence, "schemaVersion", 1, 16) != "1.0"
|
|
|| !RequiredBoolean(evidence, "buildVerified")
|
|
|| RequiredBoolean(evidence, "releaseReadiness")
|
|
|| RequiredBoolean(evidence, "sourceWorktreeDirty")
|
|
|| !Same(RequiredString(evidence, "sourceCommit", 40, 40), sourceCommit)
|
|
|| RequiredString(evidence, "configuration", 1, 32) != "Release"
|
|
|| RequiredString(evidence, "platform", 1, 16) != "x86"
|
|
|| RequiredString(evidence, "targetFramework", 1, 16) != "v4.0"
|
|
|| RequiredString(evidence, "devExpressContract", 1, 16) != "15.2")
|
|
throw Error("customer_acceptance_bundle_legacy_invalid", "旧 ERP 构建证据未绑定干净的相同源码提交。");
|
|
JObject authenticode = RequiredObject(evidence, "authenticode");
|
|
string certificateThumbprint =
|
|
WindowsTrustedPeopleSignatureVerifier.NormalizeThumbprint(
|
|
RequiredString(
|
|
authenticode,
|
|
"certificateThumbprint",
|
|
40,
|
|
64));
|
|
if (!RequiredBoolean(authenticode, "signed")
|
|
|| certificateThumbprint == null)
|
|
throw Error("customer_acceptance_bundle_legacy_invalid", "旧 ERP 构建证据没有有效签名声明。");
|
|
|
|
JArray signedFiles = authenticode["files"] as JArray;
|
|
if (signedFiles == null
|
|
|| signedFiles.Count(token =>
|
|
token.Type == JTokenType.String
|
|
&& string.Equals(
|
|
token.Value<string>(),
|
|
"lserp-cli.exe",
|
|
StringComparison.OrdinalIgnoreCase)) != 1
|
|
|| signedFiles.Count(token =>
|
|
token.Type == JTokenType.String
|
|
&& string.Equals(
|
|
token.Value<string>(),
|
|
"Ls_ERP.exe",
|
|
StringComparison.OrdinalIgnoreCase)) != 1)
|
|
{
|
|
throw Error(
|
|
"customer_acceptance_bundle_legacy_invalid",
|
|
"旧 ERP 构建证据未声明签署最终 ERP 与 CLI。");
|
|
}
|
|
|
|
JArray files = evidence["files"] as JArray;
|
|
string cliSha256 = null;
|
|
string erpSha256 = null;
|
|
if (files == null || files.Count <= 0 || files.Count > 2000)
|
|
throw Error("customer_acceptance_bundle_legacy_invalid", "旧 ERP 构建证据文件清单无效。");
|
|
foreach (JToken token in files)
|
|
{
|
|
JObject file = token as JObject;
|
|
if (file == null)
|
|
throw Error("customer_acceptance_bundle_legacy_invalid", "旧 ERP 构建证据文件清单无效。");
|
|
EnsureExact(file, "path", "sizeBytes", "sha256");
|
|
string relative = RequiredString(file, "path", 1, 256);
|
|
RequiredLong(file, "sizeBytes", 1, 512L * 1024L * 1024L);
|
|
string hash = RequiredHash(file, "sha256");
|
|
if (string.Equals(
|
|
relative,
|
|
"Runtime/lserp-cli.exe",
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (cliSha256 != null)
|
|
throw Error("customer_acceptance_bundle_legacy_invalid", "旧 ERP 构建证据包含重复 CLI。");
|
|
cliSha256 = hash;
|
|
}
|
|
if (string.Equals(
|
|
relative,
|
|
"Runtime/Ls_ERP.exe",
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (erpSha256 != null)
|
|
throw Error(
|
|
"customer_acceptance_bundle_legacy_invalid",
|
|
"旧 ERP 构建证据包含重复 ERP 主程序。");
|
|
erpSha256 = hash;
|
|
}
|
|
}
|
|
if (cliSha256 == null || erpSha256 == null)
|
|
throw Error(
|
|
"customer_acceptance_bundle_legacy_invalid",
|
|
"旧 ERP 构建证据缺少最终 ERP 或 CLI 哈希。");
|
|
return new LegacyBuildVerificationState
|
|
{
|
|
CliSha256 = cliSha256,
|
|
ErpSha256 = erpSha256,
|
|
CertificateThumbprint = certificateThumbprint
|
|
};
|
|
}
|
|
|
|
private static Dictionary<string, ArtifactFile> ResolveArtifacts(
|
|
JObject values,
|
|
string rootPath)
|
|
{
|
|
Dictionary<string, ArtifactFile> result =
|
|
new Dictionary<string, ArtifactFile>(StringComparer.Ordinal);
|
|
HashSet<string> names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (string artifactName in ArtifactNames)
|
|
{
|
|
JObject value = RequiredObject(values, artifactName);
|
|
EnsureExact(value, "fileName", "sizeBytes", "sha256");
|
|
string fileName = RequiredString(value, "fileName", 1, 128);
|
|
if (!SafeFileName.IsMatch(fileName) || fileName == "." || fileName == ".."
|
|
|| Path.GetFileName(fileName) != fileName || !names.Add(fileName))
|
|
throw Error("customer_acceptance_bundle_artifact_invalid", "总验收包制品文件名无效或重复。");
|
|
long maximum = MaximumArtifactBytes(artifactName);
|
|
long expectedSize = RequiredLong(value, "sizeBytes", 1, maximum);
|
|
string expectedHash = RequiredHash(value, "sha256");
|
|
string fullPath = Path.GetFullPath(Path.Combine(rootPath, fileName));
|
|
if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
|
|
throw Error("customer_acceptance_bundle_artifact_invalid", "总验收包制品路径越界。");
|
|
FileInfo file = new FileInfo(fullPath);
|
|
if (!file.Exists || file.Length != expectedSize || file.Length > maximum
|
|
|| (file.Attributes & FileAttributes.ReparsePoint) != 0)
|
|
throw Error("customer_acceptance_bundle_artifact_invalid", "总验收包制品缺失、大小变化或为链接文件。");
|
|
string actualHash = Sha256File(fullPath);
|
|
if (!string.Equals(expectedHash, actualHash, StringComparison.Ordinal))
|
|
throw Error("customer_acceptance_bundle_artifact_hash_mismatch", "总验收包制品 SHA-256 不一致。");
|
|
result[artifactName] = new ArtifactFile
|
|
{
|
|
Path = fullPath,
|
|
Sha256 = actualHash,
|
|
SizeBytes = expectedSize
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static long MaximumArtifactBytes(string name)
|
|
{
|
|
if (name == "package") return 4L * 1024L * 1024L * 1024L;
|
|
if (name == "runtimeConfiguration") return 64L * 1024L;
|
|
if (name == "rolloutPolicy") return CommandRolloutPolicy.MaximumBytes;
|
|
if (name == "commercialPreflight") return 4L * 1024L * 1024L;
|
|
if (name.EndsWith(
|
|
"ReadOnlySessionPreflight",
|
|
StringComparison.Ordinal))
|
|
return 2L * 1024L * 1024L;
|
|
if (name.EndsWith(
|
|
"FieldReadOnlyValidationHandoff",
|
|
StringComparison.Ordinal))
|
|
return 256L * 1024L;
|
|
if (name == "miniMaxVisionProbe") return 64L * 1024L;
|
|
if (name == "legacyBuildEvidence") return 8L * 1024L * 1024L;
|
|
if (name.EndsWith("Acceptance", StringComparison.Ordinal))
|
|
return 256L * 1024L;
|
|
if (name.EndsWith("WriteIntegration", StringComparison.Ordinal))
|
|
return 4L * 1024L * 1024L;
|
|
return 2L * 1024L * 1024L;
|
|
}
|
|
|
|
private static string GetEvidenceRoot(string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
throw Error("customer_acceptance_bundle_input_required", "请提供证据根目录。");
|
|
try
|
|
{
|
|
DirectoryInfo directory = new DirectoryInfo(Path.GetFullPath(path));
|
|
if (!directory.Exists
|
|
|| (directory.Attributes & FileAttributes.ReparsePoint) != 0)
|
|
throw Error("customer_acceptance_bundle_input_invalid", "证据根目录不存在或为链接目录。");
|
|
return directory.FullName.TrimEnd(
|
|
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
|
+ Path.DirectorySeparatorChar;
|
|
}
|
|
catch (CommandKernelException) { throw; }
|
|
catch
|
|
{
|
|
throw Error("customer_acceptance_bundle_input_invalid", "证据根目录无效。");
|
|
}
|
|
}
|
|
|
|
private static JObject LoadStrict(string path, int maximumBytes, string label)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
throw Error("customer_acceptance_bundle_input_required", "请提供" + label + "文件。");
|
|
try
|
|
{
|
|
FileInfo file = new FileInfo(Path.GetFullPath(path));
|
|
if (!file.Exists || file.Length <= 0 || file.Length > maximumBytes
|
|
|| (file.Attributes & FileAttributes.ReparsePoint) != 0)
|
|
throw Error("customer_acceptance_bundle_input_invalid", label + "必须是大小受限的非空普通文件。");
|
|
using (FileStream stream = new FileStream(
|
|
file.FullName, FileMode.Open, FileAccess.Read, FileShare.Read))
|
|
using (StreamReader text = new StreamReader(
|
|
stream, new UTF8Encoding(false, true), true, 4096))
|
|
using (RejectCommentsJsonReader json = new RejectCommentsJsonReader(text))
|
|
{
|
|
json.DateParseHandling = DateParseHandling.None;
|
|
JObject value = JObject.Load(json, new JsonLoadSettings
|
|
{
|
|
DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error,
|
|
CommentHandling = CommentHandling.Ignore,
|
|
LineInfoHandling = LineInfoHandling.Ignore
|
|
});
|
|
if (json.Read())
|
|
throw Error("customer_acceptance_bundle_json_invalid", label + "包含多个 JSON 根值。");
|
|
return value;
|
|
}
|
|
}
|
|
catch (CommandKernelException) { throw; }
|
|
catch
|
|
{
|
|
throw Error("customer_acceptance_bundle_json_invalid", label + "不是严格 UTF-8 JSON 对象。");
|
|
}
|
|
}
|
|
|
|
private static void AssertHash(ArtifactFile artifact, string expected, string label)
|
|
{
|
|
if (!string.Equals(artifact.Sha256, expected, StringComparison.Ordinal))
|
|
throw Error("customer_acceptance_bundle_artifact_hash_mismatch", label + "与总包绑定哈希不一致。");
|
|
}
|
|
|
|
private static void RequireExpected(string actual, string expected, string label)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(expected)
|
|
&& !string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase))
|
|
throw Error("customer_acceptance_bundle_scope_mismatch", "总验收包与预期" + label + "不一致。");
|
|
}
|
|
|
|
private static bool Same(string left, string right)
|
|
{
|
|
return string.Equals(left ?? string.Empty, right ?? string.Empty,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static JObject RequiredObject(JObject source, string name)
|
|
{
|
|
JObject value = source[name] as JObject;
|
|
if (value == null)
|
|
throw Error("customer_acceptance_bundle_schema_invalid", name + " 必须是对象。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredString(JObject source, string name, int minimum, int maximum)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type != JTokenType.String)
|
|
throw Error("customer_acceptance_bundle_schema_invalid", name + " 必须是字符串。");
|
|
string value = token.Value<string>();
|
|
if (value == null || value.Length < minimum || value.Length > maximum
|
|
|| value.Any(char.IsControl))
|
|
throw Error("customer_acceptance_bundle_schema_invalid", name + " 文本格式无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredHash(JObject source, string name)
|
|
{
|
|
string value = RequiredString(source, name, 64, 64).ToLowerInvariant();
|
|
if (!Sha256Pattern.IsMatch(value))
|
|
throw Error("customer_acceptance_bundle_schema_invalid", name + " 必须是小写 SHA-256。");
|
|
return value;
|
|
}
|
|
|
|
private static bool RequiredBoolean(JObject source, string name)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type != JTokenType.Boolean)
|
|
throw Error("customer_acceptance_bundle_schema_invalid", name + " 必须是布尔值。");
|
|
return token.Value<bool>();
|
|
}
|
|
|
|
private static long RequiredLong(JObject source, string name, long minimum, long maximum)
|
|
{
|
|
JToken token = source[name];
|
|
long value;
|
|
if (token == null || token.Type != JTokenType.Integer
|
|
|| !long.TryParse(token.ToString(Formatting.None), NumberStyles.None,
|
|
CultureInfo.InvariantCulture, out value)
|
|
|| value < minimum || value > maximum)
|
|
throw Error("customer_acceptance_bundle_schema_invalid", name + " 整数格式无效。");
|
|
return value;
|
|
}
|
|
|
|
private static DateTime RequiredUtc(JObject source, string name)
|
|
{
|
|
string text = RequiredString(source, name, 20, 64);
|
|
DateTime value;
|
|
if (!DateTime.TryParse(text, CultureInfo.InvariantCulture,
|
|
DateTimeStyles.RoundtripKind, out value) || value.Kind != DateTimeKind.Utc)
|
|
throw Error("customer_acceptance_bundle_schema_invalid", name + " 必须是 UTC ISO 8601 时间。");
|
|
return value;
|
|
}
|
|
|
|
private static DateTime RequiredProbeUtc(string text, string name)
|
|
{
|
|
DateTimeOffset value;
|
|
if (string.IsNullOrEmpty(text)
|
|
|| !Regex.IsMatch(
|
|
text,
|
|
@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$",
|
|
RegexOptions.CultureInvariant)
|
|
|| !DateTimeOffset.TryParse(
|
|
text,
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.None,
|
|
out value))
|
|
throw Error("customer_acceptance_bundle_schema_invalid",
|
|
name + " 必须是精确 UTC ISO 8601 时间。");
|
|
return value.UtcDateTime;
|
|
}
|
|
|
|
private static void EnsureExact(JObject source, params string[] names)
|
|
{
|
|
if (source == null || source.Properties().Count() != names.Length
|
|
|| names.Any(name => source.Property(name, StringComparison.Ordinal) == null))
|
|
throw Error("customer_acceptance_bundle_schema_invalid", "总验收包包含缺失或未知字段。");
|
|
}
|
|
|
|
private static string Sha256File(string path)
|
|
{
|
|
using (FileStream stream = new FileStream(
|
|
path, FileMode.Open, FileAccess.Read, FileShare.Read))
|
|
using (SHA256 sha = SHA256.Create())
|
|
{
|
|
return BitConverter.ToString(sha.ComputeHash(stream))
|
|
.Replace("-", string.Empty).ToLowerInvariant();
|
|
}
|
|
}
|
|
|
|
private static string Sha256(byte[] value)
|
|
{
|
|
using (SHA256 sha = SHA256.Create())
|
|
{
|
|
return BitConverter.ToString(sha.ComputeHash(value))
|
|
.Replace("-", string.Empty).ToLowerInvariant();
|
|
}
|
|
}
|
|
|
|
private static CommandKernelException Error(string code, string message)
|
|
{
|
|
return new CommandKernelException(code, message, 6);
|
|
}
|
|
|
|
private sealed class RejectCommentsJsonReader : JsonTextReader
|
|
{
|
|
public RejectCommentsJsonReader(TextReader reader) : base(reader) { }
|
|
|
|
public override bool Read()
|
|
{
|
|
bool result = base.Read();
|
|
if (result && TokenType == JsonToken.Comment)
|
|
throw Error("customer_acceptance_bundle_json_invalid", "总验收包不允许 JSON 注释。");
|
|
return result;
|
|
}
|
|
}
|
|
|
|
private sealed class ArtifactFile
|
|
{
|
|
public string Path { get; set; }
|
|
public string Sha256 { get; set; }
|
|
public long SizeBytes { get; set; }
|
|
}
|
|
|
|
private sealed class WorkflowSummary
|
|
{
|
|
public string ModuleCode { get; set; }
|
|
public string AcceptanceEvidenceId { get; set; }
|
|
public string AcceptanceContentSha256 { get; set; }
|
|
public string WriteIntegrationContentSha256 { get; set; }
|
|
public string EnvironmentId { get; set; }
|
|
public DateTime TestedAtUtc { get; set; }
|
|
public string TestedBy { get; set; }
|
|
}
|
|
|
|
private sealed class DiagnosticSummary
|
|
{
|
|
public string ScenarioCode { get; set; }
|
|
public string EvidenceId { get; set; }
|
|
public string ContentSha256 { get; set; }
|
|
public string ModuleCode { get; set; }
|
|
public string PrimaryFindingCode { get; set; }
|
|
public DateTime CapturedAtUtc { get; set; }
|
|
}
|
|
|
|
private sealed class PreflightVerificationState
|
|
{
|
|
public string PackageSha256 { get; set; }
|
|
public string LegacyBuildEvidenceSha256 { get; set; }
|
|
public string RolloutPolicySha256 { get; set; }
|
|
public string RolloutCustomerId { get; set; }
|
|
public string MiniMaxVisionProbeEvidenceSha256 { get; set; }
|
|
public DateTime MiniMaxVisionProbeObservedAtUtc { get; set; }
|
|
public string MiniMaxVisionProbeRegion { get; set; }
|
|
public string MiniMaxVisionProbeContractVersion { get; set; }
|
|
}
|
|
|
|
private sealed class SessionScopeSummary
|
|
{
|
|
public string SubSystemId { get; set; }
|
|
public DateTime ReadOnlySessionPreflightGeneratedAtUtc { get; set; }
|
|
public DateTime FieldReadOnlyValidationHandoffGeneratedAtUtc { get; set; }
|
|
}
|
|
|
|
private sealed class ReadOnlySessionPreflightState
|
|
{
|
|
public string GeneratedAtUtcText { get; set; }
|
|
public DateTime GeneratedAtUtc { get; set; }
|
|
public int ErpProcessId { get; set; }
|
|
public string ErpSha256 { get; set; }
|
|
public string ErpSignerThumbprint { get; set; }
|
|
public string RuntimeCliVersion { get; set; }
|
|
public string RuntimeCliSha256 { get; set; }
|
|
public string RuntimeCliSignerThumbprint { get; set; }
|
|
public int ModuleCount { get; set; }
|
|
public bool IsAdministrator { get; set; }
|
|
}
|
|
|
|
private sealed class FieldReadOnlyValidationHandoffState
|
|
{
|
|
public string GeneratedAtUtcText { get; set; }
|
|
}
|
|
|
|
private sealed class MiniMaxVisionProbeState
|
|
{
|
|
public string ObservedAtUtcText { get; set; }
|
|
public DateTime ObservedAtUtc { get; set; }
|
|
public string Region { get; set; }
|
|
public string ContractVersion { get; set; }
|
|
}
|
|
|
|
private sealed class LegacyBuildVerificationState
|
|
{
|
|
public string CliSha256 { get; set; }
|
|
public string ErpSha256 { get; set; }
|
|
public string CertificateThumbprint { get; set; }
|
|
}
|
|
|
|
private sealed class DiagnosticVerificationState
|
|
{
|
|
public DiagnosticVerificationState()
|
|
{
|
|
EvidenceIds = new HashSet<string>(StringComparer.Ordinal);
|
|
ContentHashes = new HashSet<string>(StringComparer.Ordinal);
|
|
UserIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
UserNames = new HashSet<string>(StringComparer.Ordinal);
|
|
DatabaseScopeFingerprints = new HashSet<string>(
|
|
StringComparer.Ordinal);
|
|
ModuleCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
public ISet<string> EvidenceIds { get; private set; }
|
|
public ISet<string> ContentHashes { get; private set; }
|
|
public ISet<string> UserIds { get; private set; }
|
|
public ISet<string> UserNames { get; private set; }
|
|
public ISet<string> DatabaseScopeFingerprints { get; private set; }
|
|
public ISet<string> ModuleCodes { get; private set; }
|
|
}
|
|
}
|
|
}
|