diff --git a/.github/workflows/uniq.yaml b/.github/workflows/uniq.yaml index a21e26cc5..210f6adf1 100644 --- a/.github/workflows/uniq.yaml +++ b/.github/workflows/uniq.yaml @@ -4,9 +4,13 @@ on: push: paths: - 'client/dictionaries/**' + - 'client/resources/*.json' + - 'tools/check_json_uniq.py' pull_request: paths: - 'client/dictionaries/**' + - 'client/resources/*.json' + - 'tools/check_json_uniq.py' workflow_dispatch: @@ -20,3 +24,7 @@ jobs: run: | find . -type f -name "*.dic" | xargs -I {} sh -c "echo {} && cat {} | sed 's/ *#.*//;/^$/d' | sed 's/\(.*\)/\U\1/' | sort | uniq -i -d -c | sort -n -r " if [[ $(find . -type f -name "*.dic" | xargs -I {} sh -c "echo {} && cat {} | sed 's/ *#.*//;/^$/d' | sed 's/\(.*\)/\U\1/' | sort | uniq -i -d -c | sort -n -r " | grep -v '^\./' | wc -l) -gt 0 ]]; then exit 1; fi + + - name: check unique AIDs in resource json files + shell: bash + run: python3 tools/check_json_uniq.py diff --git a/tools/check_json_uniq.py b/tools/check_json_uniq.py new file mode 100755 index 000000000..01df3ec55 --- /dev/null +++ b/tools/check_json_uniq.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Check the AID resource files for duplicate entries. + +client/resources/aid_desfire.json and aidlist.json are looked up by AID, and +the lookup returns the FIRST match (see find_aiddf_entry() in +client/src/mifare/aiddesfire.c). A second entry with the same AID is therefore +dead data: nothing can ever print it. + +One exception is legitimate. aidsearch.c matches a card's actual response +against "ResponseRegex" to choose between entries that share an AID, so a group +whose members all carry a distinct ResponseRegex is disambiguated at runtime and +is allowed. That is how Apple, Google and Samsung all live on OSE.VAS.01. + +Run with no arguments from anywhere in the tree, or pass files explicitly. +Exits non-zero when a real duplicate is found. +""" + +import json +import os +import sys +from collections import defaultdict + +DEFAULT_FILES = [ + "client/resources/aid_desfire.json", + "client/resources/aidlist.json", +] + +DISAMBIGUATOR = "ResponseRegex" + + +def repo_root(): + here = os.path.abspath(os.path.dirname(__file__)) + return os.path.dirname(here) + + +def check(path): + """Return the number of offending AID groups in one file.""" + try: + with open(path, encoding="utf-8") as fd: + data = json.load(fd) + except FileNotFoundError: + print(" %s: not found, skipped" % path) + return 0 + except json.JSONDecodeError as exc: + print(" %s: invalid json, line %d: %s" % (path, exc.lineno, exc.msg)) + return 1 + + if not isinstance(data, list): + print(" %s: root is not an array" % path) + return 1 + + groups = defaultdict(list) + for idx, entry in enumerate(data): + if not isinstance(entry, dict) or "AID" not in entry: + continue + groups[str(entry["AID"]).upper()].append((idx, entry)) + + bad = 0 + allowed = 0 + + for aid, members in sorted(groups.items()): + if len(members) < 2: + continue + + regexes = [e.get(DISAMBIGUATOR, "") for _, e in members] + if all(regexes) and len(set(regexes)) == len(regexes): + allowed += 1 + print(" %s: AID %s appears %d times, disambiguated by %s ( allowed )" + % (path, aid, len(members), DISAMBIGUATOR)) + continue + + bad += 1 + print(" %s: AID %s appears %d times and is NOT disambiguated" % (path, aid, len(members))) + for idx, entry in members: + print(" index %-5d %-34s %s" % (idx, + entry.get("Name", ""), + entry.get("Vendor", ""))) + print(" only the first is reachable. Merge them, or give each a distinct %s" + % DISAMBIGUATOR) + + if len(groups): + print(" %s: %d entries, %d duplicate group(s) allowed, %d rejected" + % (path, len(data), allowed, bad)) + return bad + + +def main(): + files = sys.argv[1:] + if not files: + root = repo_root() + files = [os.path.join(root, f) for f in DEFAULT_FILES] + + print("Checking AID resource files for duplicate entries") + total = sum(check(f) for f in files) + + if total: + print("\nFAIL: %d duplicate AID group(s)" % total) + return 1 + + print("\nok") + return 0 + + +if __name__ == "__main__": + sys.exit(main())