using Json.Schema; using System.Security.Cryptography; using System.Text.Json; using System.Text.RegularExpressions; namespace Zcbot.WindowsNode; internal static class AdapterContractLoader { internal static AdapterDescriptor Load(string directory) { var root = Path.GetFullPath(directory); var manifestPath = ResolveFile(root, "adapter.json"); using var manifestDocument = JsonDocument.Parse(File.ReadAllBytes(manifestPath)); var manifestRoot = manifestDocument.RootElement; RequireOnlyProperties( manifestRoot, "capability", "adapter_version", "runtime", "runtime_id", "entrypoint", "contract", "worker_timeout_minutes", "running_detail"); var manifest = new AdapterManifest( RequiredString(manifestRoot, "capability"), RequiredVersion(manifestRoot, "adapter_version"), RequiredString(manifestRoot, "runtime"), OptionalString(manifestRoot, "runtime_id"), RequiredString(manifestRoot, "entrypoint"), RequiredString(manifestRoot, "contract"), OptionalInteger(manifestRoot, "worker_timeout_minutes", 30, 1, 1440), RequiredString(manifestRoot, "running_detail")); ValidateManifest(manifest); var contractPath = ResolveFile(root, manifest.Contract); var contractBytes = File.ReadAllBytes(contractPath); var contractSha256 = Convert.ToHexString(SHA256.HashData(contractBytes)).ToLowerInvariant(); using var contractDocument = JsonDocument.Parse(contractBytes); var contractRoot = contractDocument.RootElement; var capability = RequiredString(contractRoot, "capability"); if (!capability.Equals(manifest.Capability, StringComparison.Ordinal)) { throw new InvalidDataException("Adapter manifest capability does not match its contract."); } var currentVersion = ParseVersion(manifest.AdapterVersion); var features = contractRoot.GetProperty("features").EnumerateObject() .Where(item => ParseVersion(item.Value.GetString() ?? "0.0.0") <= currentVersion) .Select(item => item.Name) .ToArray(); var (workspaceStateFilename, previewOutputIds) = ReadWorkspace(contractRoot); var schema = JsonSchema.Build(contractRoot.GetProperty("request_schema").Clone()); var entrypointPath = ResolveFile(root, manifest.Entrypoint); ValidateEntrypoint(manifest, entrypointPath); return new AdapterDescriptor( root, entrypointPath, contractPath, contractSha256, manifest, new NodeAdapterContract( capability, RequiredString(contractRoot, "display_name"), features, schema, workspaceStateFilename, previewOutputIds)); } private static void ValidateManifest(AdapterManifest manifest) { if (!Regex.IsMatch(manifest.Capability, "^[a-z][a-z0-9_.-]+@v[1-9][0-9]*$")) { throw new InvalidDataException("Adapter capability is invalid."); } if (manifest.Runtime is not ("python" or "executable")) { throw new InvalidDataException("Adapter runtime must be python or executable."); } if (manifest.Runtime == "python" && (manifest.RuntimeId is null || !Regex.IsMatch(manifest.RuntimeId, "^[a-z][a-z0-9_-]{0,31}$"))) { throw new InvalidDataException("Python adapter runtime_id is invalid."); } if (manifest.Runtime == "executable" && manifest.RuntimeId is not null) { throw new InvalidDataException("Executable adapter must not declare runtime_id."); } } private static (string? StateFilename, IReadOnlyList PreviewOutputIds) ReadWorkspace( JsonElement contractRoot) { if (!contractRoot.TryGetProperty("workspace", out var workspace) || workspace.ValueKind != JsonValueKind.Object) { return (null, []); } var stateOutput = RequiredString(workspace, "state_output"); var outputs = contractRoot.GetProperty("outputs"); if (!outputs.TryGetProperty(stateOutput, out var stateSpec)) { throw new InvalidDataException("Workspace state output is missing from contract."); } var previewOutputIds = workspace.GetProperty("preview_outputs") .EnumerateArray() .Select(item => item.GetString() ?? throw new InvalidDataException("Workspace preview output is invalid.")) .ToArray(); return (RequiredString(stateSpec, "filename"), previewOutputIds); } private static void ValidateEntrypoint(AdapterManifest manifest, string entrypointPath) { if ((manifest.Runtime == "python" && !Path.GetExtension(entrypointPath).Equals(".py", StringComparison.OrdinalIgnoreCase)) || (manifest.Runtime == "executable" && !Path.GetExtension(entrypointPath).Equals(".exe", StringComparison.OrdinalIgnoreCase))) { throw new InvalidDataException("Adapter entrypoint extension does not match its runtime."); } } private static string ResolveFile(string root, string relativePath) { if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathFullyQualified(relativePath)) { throw new InvalidDataException("Adapter file path must be relative."); } var resolved = Path.GetFullPath(Path.Combine(root, relativePath)); if (!resolved.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(resolved)) { throw new InvalidDataException("Adapter file is missing or outside its directory."); } return resolved; } private static void RequireOnlyProperties(JsonElement value, params string[] names) { if (value.ValueKind != JsonValueKind.Object) { throw new InvalidDataException("Adapter manifest must be an object."); } var allowed = names.ToHashSet(StringComparer.Ordinal); if (value.EnumerateObject().Any(item => !allowed.Contains(item.Name))) { throw new InvalidDataException("Adapter manifest contains unknown properties."); } } private static string RequiredString(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(property.GetString()) ? property.GetString()! : throw new InvalidDataException($"Adapter property {name} is missing."); private static string RequiredVersion(JsonElement value, string name) { var version = RequiredString(value, name); if (!Regex.IsMatch(version, "^[0-9]+\\.[0-9]+\\.[0-9]+$") || !Version.TryParse(version, out _)) { throw new InvalidDataException($"Adapter property {name} is not a semantic version."); } return version; } private static int OptionalInteger( JsonElement value, string name, int defaultValue, int minimum, int maximum) { if (!value.TryGetProperty(name, out var property)) return defaultValue; return property.TryGetInt32(out var result) && result >= minimum && result <= maximum ? result : throw new InvalidDataException($"Adapter property {name} is invalid."); } private static string? OptionalString(JsonElement value, string name) => !value.TryGetProperty(name, out var property) || property.ValueKind == JsonValueKind.Null ? null : property.ValueKind == JsonValueKind.String ? property.GetString() : throw new InvalidDataException($"Adapter property {name} must be a string."); private static Version ParseVersion(string value) => Version.TryParse(value, out var version) ? version : new Version(0, 0, 0); }